mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-11-14 14:00:50 +00:00
update
This commit is contained in:
parent
42b234fe45
commit
ae20f10745
@ -14,7 +14,7 @@ CRMEB v8 电商系统,三端一体:
|
||||
|
||||
| 区域 | 目录 | 技术栈 |
|
||||
| --- | --- | --- |
|
||||
| 后端 | `backend/` | PHP 8.2-8.4 + ThinkPHP 8 + MySQL 5.7+(推荐 8.0)+ Redis,多应用入口 `adminapi` / `api` |
|
||||
| 后端 | `backend/` | PHP 8.2-8.4(默认 8.4)+ ThinkPHP 8 + MySQL 5.7+(推荐 8.0)+ Redis,多应用入口 `adminapi` / `api` |
|
||||
| 管理后台 | `frontend/admin/` | Vue 3.5 + Element Plus 2.9 + Pinia 2 + vue-router 4 + Vite 5(纯 JS) |
|
||||
| 移动端 | `frontend/uni-app/` | uni-app 官方 CLI(Vite 5)+ Vue 3 + Pinia + Sass |
|
||||
|
||||
@ -41,6 +41,7 @@ CRMEB v8 电商系统,三端一体:
|
||||
5. **接口契约变更要全链路同步**:后端路由/Controller/Service/DAO/Model、`frontend/admin/src/api`、`frontend/uni-app/src/api`、菜单权限、安装/升级数据。同步清单见 [.crmeb/rules/前端规范.md](rules/前端规范.md) 第三节。
|
||||
6. **Git 提交遵循 [.crmeb/rules/提交规范.md](rules/提交规范.md)**:中文描述 + 英文类型前缀、按主题分批提交、禁止提交密钥与本地环境文件。
|
||||
7. **插件二开零侵入**:插件/驱动/业务模块只能落 `backend/modules/`、`backend/driver/` 自有目录,禁止修改系统表与框架代码(`app/`、`crmeb/`、核心前端、安装 SQL、composer/config),扩展只走扩展点/事件/注册中心/Installer 标准设施。红线细则见 [.crmeb/rules/插件开发规范.md](rules/插件开发规范.md)。
|
||||
8. **编码克制**:防过度防御(极低概率边界须先讲清概率与代码量并经同意)、入口单次校验、拒绝提前抽象、控制复杂度(不滥用 try/catch、不强行拆函数)。细则见 [.crmeb/rules/编码克制规范.md](rules/编码克制规范.md)。
|
||||
|
||||
## 规则索引(`.crmeb/rules/`)
|
||||
|
||||
@ -51,6 +52,7 @@ CRMEB v8 电商系统,三端一体:
|
||||
| [架构规范详解.md](rules/架构规范详解.md) | 目录结构、模块注册、消息通知、驱动后台、路由自动加载、菜单路径 | 涉及架构/扩展点时 |
|
||||
| [插件开发规范.md](rules/插件开发规范.md) | 插件二开强制边界:禁改系统表与框架代码、自有目录与自有表、扩展方式、卸载可还原、插件包与升级包交付(upgrades 脚本链、版本双源一致、自动归档)、违规判定 | 开发/修改任何插件、驱动、业务模块及配套前端资源前;发布插件包/升级包时 |
|
||||
| [代码规范.md](rules/代码规范.md) | 控制器命名、Services/DAO 属性注入、路由定义格式 | 写后端代码时 |
|
||||
| [编码克制规范.md](rules/编码克制规范.md) | 防过度防御、入口单次校验、拒绝提前抽象、控制复杂度 | 写任何业务代码时 |
|
||||
| [事件规范.md](rules/事件规范.md) | 事件机制与全量事件名清单 | 新增/修改事件时 |
|
||||
| [订单中台规范.md](rules/订单中台规范.md) | 订单中台架构规则与订单流程 | 订单相关开发 |
|
||||
| [前端规范.md](rules/前端规范.md) | 管理端/移动端强制红线、接口契约变更全链路同步清单 | 任何前端改动、接口契约变更时 |
|
||||
@ -74,11 +76,11 @@ CRMEB v8 电商系统,三端一体:
|
||||
| 技能 | 何时用 |
|
||||
| --- | --- |
|
||||
| `crmeb-project-dev` | 项目框架、`backend/app`、`backend/crmeb`、`backend/driver`、共享服务、API、数据库变更、跨端功能 |
|
||||
| `crmeb-plugin-dev` | 可安装插件、驱动与 `backend/modules/` 业务模块:`plugin.json`、Installer 生命周期、菜单、种子、打包、admin UMD 资源、领域归属、Handler 注册、事件路由、定时任务、消息通知、MCP 工具扩展 |
|
||||
| `crmeb-plugin-dev` | 可安装插件、驱动与 `backend/modules/` 业务模块:先读 `references/catalog/00-总览-类型路由与边界.md` 再读对应 `01-驱动` / `02-模块` 分册;`plugin.json`、Installer 生命周期、菜单、种子、打包、admin UMD 资源、uni-app `pages-modules` 独立页放入后的跳转与装修(`references/uniapp-pages.md`)、领域归属、Handler 注册、事件路由、定时任务、消息通知、MCP 工具扩展 |
|
||||
| `crmeb-order-dev` | 订单开发手册:交易模式、产品类型与订单中台,订单全生命周期(创建/支付/履约/收货/评价/取消/售后退款)开发说明 | 订单、交易模式、支付、售后、订单状态相关开发 |
|
||||
| `crmeb-admin-dev` | 管理后台页面/组件/接口/路由/插件页面开发 |
|
||||
| `crmeb-admin-ui` | 管理端 UI 规范:设计令牌、布局、页面骨架、组件与交互、深色模式、评审清单 |
|
||||
| `crmeb-uniapp-dev` | 移动端 `frontend/uni-app` 页面、API 封装、条件编译、分包、动态渲染 |
|
||||
| `crmeb-uniapp-dev` | 移动端 `frontend/uni-app` 页面、API 封装、条件编译、分包、`pages-modules` 插件页、动态渲染 |
|
||||
| `crmeb-dev` | 二开总览与流程导航(含 `references/` 分类子文档,如创建插件/驱动/产品类型/用户端页面) |
|
||||
| `project-standard-bootstrap` | 项目统一工程规范引导:审计/初始化/修复 `.crmeb` 规则骨架、工具入口、目录契约与文档体系(模板类技能,仅按需用于规范化治理) | 「项目规范化」「工程规范审计」「新项目初始化」 |
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
| 项目约定与任务路由 | [AGENTS.md](AGENTS.md) | 决定本次任务需要读取哪些规则和技能 |
|
||||
| 开发规则 | [规则索引](AGENTS.md#规则索引crmebrules) | 架构、代码、数据库、安全、接口等强制约束 |
|
||||
| 操作手册 | [技能索引](AGENTS.md#技能索引crmebskills) | 8 个专项技能,详细教程在各自 `references/` 中 |
|
||||
| 插件与驱动类型分册 | [类型总览](skills/crmeb-plugin-dev/references/catalog/00-总览-类型路由与边界.md) | 开发插件或驱动时先读总览,再读对应 `01-驱动` / `02-模块` 分册 |
|
||||
| 专项角色 | [角色索引](AGENTS.md#子智能体索引crmebagents) | 架构检查、模块构建、路由校验的执行流程 |
|
||||
| 各端补充约定 | [管理端](scopes/admin.md)、[移动端](scopes/uni-app.md) | 只在对应端任务中读取 |
|
||||
| 项目说明 | [文档索引](../help/docs/文档索引.md) | 项目、架构、模块与运行说明,不代替当前规则或执行授权 |
|
||||
|
||||
@ -110,7 +110,7 @@ CRMEB v8 采用三层架构,事件监听器分布在核心层和模块层:
|
||||
|
||||
> **本节为「判定清单 / 参考数据」,不是强制条款**:约束(字符串 key 调用格式、参数数组规范、监听器写法)以本文第 2 节「事件调用规范」与第 5 节「最佳实践」为准。本清单仅用于查证"某个事件是否存在、在哪注册、由谁触发",与代码不一致时以 `backend/app/event.php`、各模块 `event.php` 与实际代码为准。
|
||||
|
||||
> 以下按业务域分类列出所有事件名。同一事件可被多个模块注册监听(如 `OrderPaySuccess` 同时被 app 核心、normal 产品类型、agent 分销、combination 拼团注册)。
|
||||
> 以下按业务域分类列出所有事件名。同一事件可被多个模块注册监听(如 `OrderPaySuccess` 同时被 app 核心、normal 产品类型、agent 分销注册)。
|
||||
|
||||
### 4.1 用户事件
|
||||
|
||||
@ -145,7 +145,7 @@ CRMEB v8 采用三层架构,事件监听器分布在核心层和模块层:
|
||||
| 事件名 | 触发场景 | 注册位置 |
|
||||
|--------|---------|---------|
|
||||
| `OrderCreateAfter` | 下单成功 | app/event.php, product/normal |
|
||||
| `OrderPaySuccess` | 支付成功 | app/event.php, product/normal, product/virtual, product/ticket, trade/combination, agent |
|
||||
| `OrderPaySuccess` | 支付成功 | app/event.php, product/normal, product/virtual, product/ticket, agent |
|
||||
| `OrderCancel` | 订单取消 | product/normal, product/virtual, trade/integral |
|
||||
| `OrderComplete` | 订单完成 | product/normal |
|
||||
| `OrderDelivery` | 发货/核销 | product/normal |
|
||||
@ -256,6 +256,7 @@ CRMEB v8 采用三层架构,事件监听器分布在核心层和模块层:
|
||||
|--------|---------|---------|
|
||||
| `HttpEnd` | HTTP 请求结束 | app/event.php |
|
||||
| `AdminLogin` | 管理员登录 | app/event.php |
|
||||
| `SmsBalanceCheck` | 一号通短信余额检查(登录/后台消息旁路,监听器内 10 分钟节流) | app/event.php |
|
||||
| `Notify` | 支付回调通知 | app/event.php |
|
||||
| `OutPush` | 外部推送 | app/event.php |
|
||||
| `CrontabBeforeRun` | 定时任务执行前 | app/event.php |
|
||||
|
||||
@ -11,7 +11,7 @@ description: 前端强制红线、接口契约变更同步清单与管理端 UI
|
||||
1. **主题令牌**:样式只允许使用 `--prev-*` 设计令牌,禁止硬编码主题色/十六进制色值;与 Element Plus 的桥接在 `src/theme/app.scss`。
|
||||
2. **页面骨架**:页面用卡片分块(搜索卡 + 数据卡);按钮绑定 `v-db-click`;权限按钮带 `v-auth="['perm-string']"`,路由带 `meta.auth`。
|
||||
3. **鉴权头拼写**:请求头是 `Authori-zation: Bearer <token>`(后端约定),**禁止"修正"为标准拼写**。
|
||||
4. **废弃代码勿用**:`src/libs/axios.js` 是废弃遗留,请求一律 `@/libs/request`。
|
||||
4. **请求封装**:请求一律 `@/libs/request`。已删除的 `src/libs/axios.js`、`src/libs/api.request.js` 不要恢复。
|
||||
5. **插件路由**:新增 plugin 子目录 `routes.js` 不会被热更新,**必须重启 dev server**。
|
||||
6. **事件总线**:`@/utils/bus` 的 `off` 必须传与 `on` 相同的具名函数引用。
|
||||
7. **编辑纪律**:同一文件多处修改必须逐处顺序编辑,禁止同批并行编辑同一文件(会互相覆盖静默丢失改动)。
|
||||
|
||||
@ -81,7 +81,7 @@ description: 插件二开强制边界(强约束)- 插件代码只能位于 b
|
||||
37. **升级 = 完整目录替换,禁出 diff 包**:向已存在的插件目录上传包即按升级处理,要求 `key`/`type`/`path` 与已装插件同一版本线;旧目录整体备份到 `modules/backup/.plugin_backup/` 并在失败时回滚。因此插件升级包必须携带**完整的新版插件目录**,不得只打包改动文件。
|
||||
38. **升级迁移脚本**:涉及已安装站点的数据/结构变更,必须在插件根目录的 `upgrades/` 子目录归档迁移脚本,文件名格式「{起始版本}_to_{目标版本}.php」(如 `1.0.0_to_1.1.0.php`),文件返回一个可调用对象。服务端从当前安装版本沿链逐级执行到目标版本:跨多级版本必须提供完整脚本链;包内无任何脚本视为纯代码升级;**有脚本但断链必然升级失败**。脚本只允许操作插件自有表(第 11~14 条对升级脚本同样生效),必须幂等。
|
||||
39. **升级不重跑生命周期**:插件已安装,升级不会再次执行 `install()` 及启用时的资源部署。新版本若新增菜单、种子数据、共享表登记、DIY 链接,须在升级脚本内做等效操作(复用 Installer 标准设施语义);若更新了随插件分发的模块自有前端产物(第 23 条所述部署资源),交付说明必须注明"升级后将插件禁用再启用"以触发第 23 条幂等部署,并验证扩展资源生效后才可宣告升级完成。
|
||||
40. **交付前自动归档插件包**(与升级包规范第 5 节对称):任务改动插件自有目录(含 `plugin.json` 的 `modules/**` 模块、`driver/{category}/{name}` 驱动)时,提交前自动执行:① 按升级包规范第 2 节确认改动不入核心升级包;② 双源递增版本号(第 36 条);③ 有数据/结构变更则补 `upgrades/` 迁移脚本(第 38 条);④ 配套前端扩展先完成产物构建(第 28 条);⑤ 压缩插件根目录,输出到 tests 过程产物区的 plugin-packages 子目录、文件名含 key 与版本号(该目录已在 .gitignore 排除:zip 为发布产物不入库,真源是插件目录本身,出包时现生成);⑥ 临时库端到端验证:首发跑安装路径,非首发跑"装旧版 → 上传升级包"路径;⑦ 在交付说明中报告插件包路径、key 与覆盖的版本区间。
|
||||
40. **交付前自动归档插件包**(与升级包规范第 5 节对称):任务改动插件自有目录(含 `plugin.json` 的 `modules/**` 模块、`driver/{category}/{name}` 驱动)时,提交前自动执行:① 按升级包规范第 2 节确认改动不入核心升级包;② 双源递增版本号(第 36 条);③ 有数据/结构变更则补 `upgrades/` 迁移脚本(第 38 条);④ 配套前端扩展先完成产物构建(第 28 条);⑤ 压缩插件根目录,输出到仓库根目录 `.plugin-packages/`、文件名含 key 与版本号(该目录被 `.*/` 忽略:zip 为发布产物不入库,真源是插件目录本身,出包时现生成);⑥ 临时库端到端验证:首发跑安装路径,非首发跑"装旧版 → 上传升级包"路径;⑦ 在交付说明中报告插件包路径、key 与覆盖的版本区间。
|
||||
|
||||
## 八、违规判定(验收红线)
|
||||
|
||||
|
||||
@ -111,6 +111,7 @@ backend/
|
||||
│
|
||||
├── crmeb/ 核心底层(稳定不变)
|
||||
│ ├── basic/ 基础类
|
||||
│ ├── channel/ 用户渠道认证契约(ChannelAuthenticatorInterface/BaseChannelAuthenticator,提供授权类登录的渠道模块 Authenticator 必须继承实现,经 ChannelServiceProxy 事件被 AuthController 调度)
|
||||
│ ├── trade/ 交易领域
|
||||
│ │ ├── mode/ 交易模式(TradeModeHandlerInterface/TradeModeRegistry/BaseTradeModeHandler/DefaultTradeModeHandler)
|
||||
│ │ ├── order/ 订单类型 + 售后规则 + 创建管线(type/create/aftersale)
|
||||
|
||||
@ -44,7 +44,7 @@ description: 目录与文档规范(全仓库唯一真源)- 三类知识目
|
||||
|
||||
## 五、文档更新流程(强制)
|
||||
|
||||
1. 每次新增、修改或删除文档,提交前必须**全量运行检查**:先运行 `php think docs:check` 检查 Markdown、frontmatter、索引与路径,再从仓库根运行 `node scripts/check-doc-facts.mjs` 校验模块/驱动/默认安装/基础表数量及根入口文档链接。前者需 PHP ≥8.2;无本地环境时用项目镜像一次性容器挂载仓库根执行,注意运行中的 compose 容器只挂载 `backend/`,看不到全仓库。
|
||||
1. 每次新增、修改或删除文档,提交前必须**全量运行检查**:先运行 `php think docs:check` 检查 Markdown、frontmatter、索引与路径,再从仓库根运行 `node scripts/check-doc-facts.mjs` 校验模块/驱动/默认安装/基础表数量及根入口文档链接。前者需 PHP ≥8.2;无本地环境时用项目默认镜像 `crmeb_php:8.4` 一次性容器挂载仓库根执行,注意运行中的 compose 容器只挂载 `backend/`,看不到全仓库。
|
||||
2. error 级问题(死链、frontmatter 违规)必须清零后才能提交。
|
||||
3. warning 级须逐条确认:属真漂移的当次修复或转立项;属误报的补充豁免并在命令内注明原因,禁止无说明忽略。
|
||||
4. 修复死链时同步核对目标是否为唯一真源,禁止把链接改向非真源副本。
|
||||
|
||||
43
.crmeb/rules/编码克制规范.md
Normal file
43
.crmeb/rules/编码克制规范.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
alwaysApply: false
|
||||
description: 编码克制规范 - 防过度防御、入口单次校验、拒绝提前抽象、控制复杂度。写任何业务代码时读;禁止私自加冗余防御、各层重复判空、一次性逻辑乱拆函数、滥用 try/catch
|
||||
---
|
||||
# 编码克制规范
|
||||
|
||||
> 适用范围:后端、管理端、移动端。与「安全红线」「接口校验」「异常必须处理」不冲突——本规范约束的是**多余**的防御、抽象与拆分,不削弱必要的安全与入口校验。
|
||||
|
||||
## 一、防过度防御(强制)
|
||||
|
||||
1. **几乎不会发生的边界 / 并发问题,禁止私自加防御代码。**
|
||||
2. 若认为有必要加:先说明**出错概率**与**预计增加的代码量**,**经同意后再加**。
|
||||
3. 禁止「以防万一」式空判断、双重加锁、重复状态机校验等冗余逻辑;真实线上风险(支付、库存、资金、鉴权)按既有安全/业务规范处理,不在此列。
|
||||
|
||||
## 二、入口单次校验(强制)
|
||||
|
||||
4. **外部数据只在入口层校验一次**(Controller / Validate / 请求边界);通过后,内部靠类型与约定保证格式。
|
||||
5. **禁止** Service / Dao / 工具函数各层反复判空、反复做同类防御性检查、反复转型同一入参。
|
||||
6. 内部函数假定调用方已满足契约;契约未满足应视为编程错误(或入口漏检),而不是在深处再补一套校验。
|
||||
|
||||
> 与 [接口与响应规范.md](接口与响应规范.md)「所有入参必须校验」一致:校验在入口做全,不是不做,也不是层层做。
|
||||
|
||||
## 三、拒绝提前抽象(强制)
|
||||
|
||||
7. **只用一次的逻辑直接写在原处**,禁止为「以后可能复用」拆出无调用方的辅助函数 / 工具类 / Trait。
|
||||
8. 出现**第二次及以上**真实复用、或同一段逻辑已明显阻碍阅读时,再抽取;抽取时保持命名与职责单一,禁止空壳包装。
|
||||
|
||||
## 四、控制复杂度(强制)
|
||||
|
||||
9. **非必要不滥用 try/catch**:仅在有明确降级、资源清理、或必须吞并转义的边界使用;禁止用大段 try/catch 代替前置条件与正常控制流。空 catch、无差别吞异常仍按 [日志与异常规范.md](日志与异常规范.md) 禁止。
|
||||
10. **能在一个函数里写清楚的逻辑,不要强行拆成多个函数**;拆分仅用于:职责已独立、复用已成立、或单函数明显过长且可读性下降。
|
||||
11. 禁止为「看起来分层更细」而制造仅被调用一次、且无独立语义的私有方法链。
|
||||
|
||||
## 五、违规判定(简表)
|
||||
|
||||
| 行为 | 判定 |
|
||||
| --- | --- |
|
||||
| 未经同意为极低概率边界堆防御代码 | ❌ |
|
||||
| 入口已 Validate,Service/Dao 再重复同条件判空 | ❌ |
|
||||
| 单次使用的逻辑拆成独立 helper | ❌ |
|
||||
| 可用 if/early-return 却包一层无意义 try/catch | ❌ |
|
||||
| 为凑「小函数」把连贯逻辑拆成无语义碎片 | ❌ |
|
||||
| 入口完整校验 + 内部按类型推进;必要抽象有 ≥2 处复用 | ✅ |
|
||||
@ -9,9 +9,9 @@ CRMEB 开源版(KY)管理后台,基于 **Vue 3.5 + element-plus 2.9 + Pini
|
||||
|
||||
## 常用命令
|
||||
|
||||
- Node 需 `>=18.20.0`,npm >= 7;使用 `npm ci` 安装锁定依赖。为兼容 Node 18,`sass`、`unplugin-auto-import`、`unplugin-vue-components` 固定版本,升级时检查间接依赖并验证启动、主包及扩展构建。
|
||||
- Node 需 `>=18.20.0`,npm >= 7;使用 `npm ci` 安装锁定依赖。`xlsx` 取自 SheetJS 官方 CDN(`https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz`,npm 仓库停在有漏洞的 0.18.5),安装环境需能访问该域名,升级时替换 tarball 地址。为兼容 Node 18,`sass`、`unplugin-auto-import`、`unplugin-vue-components` 固定版本,升级时检查间接依赖并验证启动、主包及扩展构建。
|
||||
- `npm run dev` — Vite dev server,端口 **1619**(被占用时自动顺延为 1620 等,以终端实际输出为准),自动开浏览器;`/adminapi` 代理到 `http://localhost:8011`(需后端在跑)。
|
||||
- `npm run build` — 生产构建到 `dist/`(`base: '/admin'`)。
|
||||
- `npm run build` — 生产构建到 `dist/`(`base: '/admin/'`)。
|
||||
- `npm run build:ext` / `npm run dev:ext` — 扩展 UMD 包(`scripts/build-extensions.js` + `scripts/vite.ext.config.js`,支持 `--ext <key>` / `--watch`);`build:all` = build + build:ext。
|
||||
- `npm run eslint` — 检查并自动修复 `src`;`npm run prettier`。
|
||||
- **无测试套件**;ESLint 即 prettier 推荐配置,无额外规则。
|
||||
@ -19,7 +19,7 @@ CRMEB 开源版(KY)管理后台,基于 **Vue 3.5 + element-plus 2.9 + Pini
|
||||
## 接口地址配置
|
||||
|
||||
- `src/setting.js` 的 `apiBaseURL`:`import.meta.env.VITE_APP_API_URL` 优先(`.env`/`.env.dev`/`.env.production`),留空回退同源 `${location.origin}/adminapi`。旧版「硬编码 `||` 链短路 env」的坑已修复,切换后端地址改 `.env*` 即可。
|
||||
- `routePre: '/admin'`,与 Vite `base: '/admin'` 一致(非根路径部署,构建产物挂在 `/admin` 下)。
|
||||
- `routePre: '/admin'`,与 Vite `base: '/admin/'` 对应(非根路径部署,构建产物挂在 `/admin` 下;base 必须带结尾斜杠,否则 `%BASE_URL%` 会拼出 `/adminfavicon.ico`)。
|
||||
- `.env.production` 的 `VITE_APP_API_URL` 留空 = 同源 `/adminapi`(同域部署默认形态);websocket 地址在 `.env` 的 `VITE_APP_WS_*`。
|
||||
|
||||
## 目录结构(src/)
|
||||
|
||||
@ -64,7 +64,7 @@ CRMEB 开源版(KY)移动端,基于 **uni-app(Vue 3 + `<script setup>`
|
||||
- **原理**:本版本 uni 编译器(`@dcloudio` 3.0.0-5020420260813003)没有官方目录注册机制,`src/pages.json` 是路由唯一输入。工程在 `vite.config.js` 里调用 `scripts/uni-auto-subpackages.mjs`,对"读取 src/pages.json"做路径级 `fs.readFileSync` 拦截,把 `pages-modules/` 扫描出的分包条目**在内存中 append-only 合并**进返回文本——磁盘上的 `pages.json` 保持字节不变(255 处注释与 `// #ifdef` 块全部保留)。uni 官方在 `@dcloudio/vite-plugin-uni` 内部用同样手法拦截 scss 读取(`rewriteScssReadFileSync`)。
|
||||
- **约定**:`src/pages-modules/<snake_case 模块名>/` = 一个独立分包,root 为 `pages-modules/<模块名>`;模块内 `.vue` 自动注册为页面(路径 = 去扩展名的相对路径,如 `/pages-modules/lottery/index`),排除 `components/`、`component/`、`static/` 角色目录与 `_` 前缀文件/目录(`_template/` 模板因此永不参与编译)。页面目录同样遵守 snake_case + `index.vue`(`lint:naming` 已覆盖本目录并强制页面入口唯一形态)。**模块目录名即插件标识**(与后端 `modules/<标识>`、自有表前缀 `eb_{标识}_*` 对齐);营销插件(秒杀等)命名细则与对照表见 `docs/命名规范.md` §2.1。
|
||||
- **片段模式**:模块根放自己的 `pages.json`(JSONC,`{ "pages": [{ "path", "style" }] }`)显式声明页面与标题/导航样式;提供后该模块不再自动扫描,完全按片段注册。
|
||||
- **跳转**:模块自带 `routes.js` 路由常量(参考 `_template/routes.js`),模块内外互跳各自引用常量、禁止硬编码路径,也不改系统 `dynamic-pages/api/routes.js`。
|
||||
- **跳转**:模块自带 `routes.js` 路由常量(参考 `_template/routes.js`),模块内外互跳引用该常量,也不改系统 `dynamic-pages/api/routes.js`。放入之后如何打开页面、如何写入装修链接,见 [.crmeb/skills/crmeb-plugin-dev/references/uniapp-pages.md](../skills/crmeb-plugin-dev/references/uniapp-pages.md)。
|
||||
- **边界**:只能新增分包页面;tabBar、easycom、globalStyle、preloadRule 等全局配置变更仍须改 `src/pages.json`。微信小程序单包 2MB / 总包 30MB 限制照常适用(每模块 = 一分包)。dev 长跑进程中 easycom 等次要消费点有一次缓存,极端情况重启 dev server;路由主链路与 build 产物不受影响(dev watch 下丢文件 15s 内自动重编,已实测)。
|
||||
- **回归口径**:`@dcloudio/*` 整组锁版升级后,须复验注入仍生效——`npm run build:mp-weixin` 后检查 `dist/build/mp-weixin/app.json` 的 subPackages 含 `pages-modules/*`。日常调试:`node scripts/uni-auto-subpackages.mjs [--print]`。
|
||||
|
||||
@ -138,4 +138,4 @@ CRMEB 开源版(KY)移动端,基于 **uni-app(Vue 3 + `<script setup>`
|
||||
- 模板静态资源根路径 `/pages/...` 在 CLI 下不可解析(vite root 为项目根),改用 `@/pages/...`;
|
||||
- rollup 严格校验命名导出:`libs/wechat.js`(纯 H5 模块)补非 H5 空默认导出、`api.js` 的 `switchH5Login` 补 `H5 || APP-PLUS` 分支;
|
||||
- `App.vue` 全局样式块需 `lang="scss"`(其中 `@import` 了含 SCSS 注释的 .scss 文件)。
|
||||
- 已知事项:微信主包体积实测 ~1.56MB(2026-08 `build:mp-weixin` 产物口径,主包 = 总量 − 各分包 root),未超 2MB 上限;此前「~3.2MB」记录疑为开发者工具统计口径差异,若上传报超限再专项优化——可优化点:主包页面(index/user 等)直接引用的 `components/` 组件、`dynamic-pages/` 全量、static 未瘦身(productSections 卡片已收敛至 diyComponents 分包);CSS 中 `url(/pages/...)` 绝对路径与 p/img/span 标签选择器在小程序端的警告为存量问题。
|
||||
- 已知事项:微信主包体积实测 ~1.29MB(2026-11 `build:mp-weixin` 产物口径,主包 = 总量 − 各分包 root;此前 ~1.6MB,已裁剪 animate.css 为实际用到的 5 个动画、数字字体子集化为 Latin,全局样式/字体移至 `src/styles/` 避免 static 原样拷贝)。新增动画需从 animate.css 3.7.2 补对应 keyframes;字体需新字符时按原字体重新子集化。首页 `preloadRule` 预下载 `subpackage/diyComponents`。剩余可选优化:`dynamic-pages/` 中仅被 `pages/center` 使用的引擎与组件(约 220KB)仍在主包,`optimization.subPackages` 只下沉 JS 公共块、不移动组件文件,需搬目录才能下沉;CSS 中 `url(/pages/...)` 绝对路径与 p/img/span 标签选择器在小程序端的警告为存量问题。
|
||||
|
||||
@ -6,7 +6,7 @@ Admin 位于 `frontend/admin/`,使用 **Vite 5**;Node 版本与依赖安装
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server,端口 1619(占用时顺延),/adminapi 代理到 localhost:8011
|
||||
npm run build # 生产构建到 dist/(base: '/admin')
|
||||
npm run build # 生产构建到 dist/(base: '/admin/')
|
||||
npm run build:ext # 构建 UMD 扩展(--ext <key> 指定单个,--watch 监听)
|
||||
npm run build:all # build + build:ext
|
||||
npm run eslint # 检查并自动修复 src
|
||||
|
||||
@ -238,7 +238,7 @@ onMounted(() => { getList(); });
|
||||
- 数据以 `ref` 为主;查询对象 `ref({ page: 1, limit: 15, ... })` 或 `reactive({...})` 均可。
|
||||
- **消息提示统一 `import { ElMessage } from 'element-plus'`**;不使用 `$msgErr`(不存在)。
|
||||
- 子组件方法需被父级 `$refs` 调用时 `defineExpose`(如 `defineExpose({ modals })`)。
|
||||
- API 函数模式:`export function userList(data) { return request({ url: 'user/user', method: 'get', params: data }); }`,统一 `import request from '@/libs/request'`(`libs/axios.js` 是废弃遗留勿用)。
|
||||
- API 函数模式:`export function userList(data) { return request({ url: 'user/user', method: 'get', params: data }); }`,统一 `import request from '@/libs/request'`(已删除的 `libs/axios.js` 不要恢复)。
|
||||
|
||||
## 四、列表页骨架(搜索卡 + 数据卡 + 分页区)
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ description: CRMEB v8 二开流程导航。需要选择插件、驱动、产品
|
||||
## 规则与验证
|
||||
|
||||
- 架构和扩展边界:[架构规范](../../rules/架构规范.md)、[架构规范详解](../../rules/架构规范详解.md)。
|
||||
- 编码、事件与持久化:[代码规范](../../rules/代码规范.md)、[事件规范](../../rules/事件规范.md)、[数据库规范](../../rules/数据库规范.md)。
|
||||
- 编码、事件与持久化:[代码规范](../../rules/代码规范.md)、[编码克制规范](../../rules/编码克制规范.md)、[事件规范](../../rules/事件规范.md)、[数据库规范](../../rules/数据库规范.md)。
|
||||
- 订单行为:[订单中台规范](../../rules/订单中台规范.md);安全、接口、日志、性能等规则按[规则索引](../../AGENTS.md)选择。
|
||||
- 安装与版本策略以架构规范中的「当前发布阶段」为准,不照搬旧教程的升级流程。
|
||||
- 运行命令与验证范围见 [验证与运行环境](../crmeb-project-dev/references/verification.md)。安装、迁移、卸载、清理数据库等操作仍需任务授权。
|
||||
@ -33,6 +33,7 @@ description: CRMEB v8 二开流程导航。需要选择插件、驱动、产品
|
||||
|
||||
| 场景 | 参考手册 |
|
||||
| --- | --- |
|
||||
| 插件或驱动选型(先读总览,再读对应 01-驱动 / 02-模块分册) | [00-总览-类型路由与边界.md](../crmeb-plugin-dev/references/catalog/00-总览-类型路由与边界.md) |
|
||||
| 系统插件管理机制(运行时注册、安装/卸载/启用/禁用、清单与身份、路由加载) | [01-插件机制/module-mechanism.md](references/01-插件机制/module-mechanism.md) |
|
||||
| 插件与生命周期的完整示例(创建/打包) | [crmeb-plugin-dev/references/manifest-and-lifecycle.md](../crmeb-plugin-dev/references/manifest-and-lifecycle.md) |
|
||||
| 系统驱动机制(管理、注册、调用链、配置存储、生命周期) | [02-驱动机制/driver-mechanism.md](references/02-驱动机制/driver-mechanism.md) |
|
||||
@ -40,6 +41,7 @@ description: CRMEB v8 二开流程导航。需要选择插件、驱动、产品
|
||||
| 产品类型、Handler、订单适配器 | [03-系统商品说明/product-type.md](references/03-系统商品说明/product-type.md) |
|
||||
| 系统订单架构(订单中台代码定位、调用链、排查路径) | [15-系统订单架构/order-architecture.md](references/15-系统订单架构/order-architecture.md) |
|
||||
| 管理端 UMD 扩展 | [04-前端扩展/backend-frontend.md](references/04-前端扩展/backend-frontend.md) |
|
||||
| 用户端插件页(pages-modules 放入后的跳转与装修) | [uniapp-pages.md](../crmeb-plugin-dev/references/uniapp-pages.md) |
|
||||
| SchemaPage 通用管理页面 | [12-通用页面/schema-page.md](references/12-通用页面/schema-page.md) |
|
||||
|
||||
## 用户端业务流程
|
||||
|
||||
@ -9,14 +9,14 @@
|
||||
| 容器名 | 镜像 | 宿主机端口 → 容器端口 | 说明 |
|
||||
|--------|------|----------------------|------|
|
||||
| `crmeb_nginx` | nginx:v1.29.6 | `8011` → 80 | Web 入口,vhost 配置 `docker/nginx/vhost.conf`(已入库) |
|
||||
| `crmeb_php` | crmeb_php:8.2 | `9000` | PHP-FPM,代码挂载 `backend/` → `/var/www`;镜像由 `docker/php/Dockerfile` 构建(已入库) |
|
||||
| `crmeb_timer` | crmeb_php:8.2 | — | 定时任务(`php think timer start`) |
|
||||
| `crmeb_queue` | crmeb_php:8.2 | — | Redis 队列消费(`docker/queue-worker.sh`,只读挂载为容器内 `/repo/docker/queue-worker.sh`) |
|
||||
| `crmeb_workerman` | crmeb_php:8.2 | `40001` | WebSocket 网关与 Channel 服务 |
|
||||
| `crmeb_php` | crmeb_php:8.4 | `9000` | PHP-FPM,代码挂载 `backend/` → `/var/www`;镜像由 `docker/php/Dockerfile` 构建(已入库) |
|
||||
| `crmeb_timer` | crmeb_php:8.4 | — | 定时任务(`php think timer start`) |
|
||||
| `crmeb_queue` | crmeb_php:8.4 | — | Redis 队列消费(`docker/queue-worker.sh`,只读挂载为容器内 `/repo/docker/queue-worker.sh`) |
|
||||
| `crmeb_workerman` | crmeb_php:8.4 | `40001` | WebSocket 网关与 Channel 服务 |
|
||||
| `crmeb_mysql` | mysql:v8.0.45 | `33061` → 3306 | Docker 默认 MySQL 8.0;代码/安装 SQL 兼容 MySQL 5.7+,数据目录 `docker/mysql/data/` |
|
||||
| `crmeb_redis` | redis:v8.6.1 | `63791` → 6379 | Redis,密码 `123456`(本地默认) |
|
||||
|
||||
> PHP 支持范围 **8.2-8.4**,四个 PHP 容器共用同一镜像,镜像标签默认取支持范围下限 `crmeb_php:8.2`。验证其它版本:`PHP_VERSION=8.3 docker compose up -d --build`(8.4 同理),构建参数见 `docker/php/Dockerfile`。
|
||||
> PHP 支持范围 **8.2-8.4**,四个 PHP 容器共用同一镜像,镜像标签默认 `crmeb_php:8.4`。验证其它版本:`PHP_VERSION=8.2 docker compose up -d --build`(8.3 同理),构建参数见 `docker/php/Dockerfile`。
|
||||
|
||||
**关键经验**:容器主机名(`crmeb_mysql`、`crmeb_redis`)**仅在 Docker 网络内可解析**(PHP 应用内连接数据库用它们),宿主机工具(Navicat、redis-cli 等)必须用 `localhost + 映射端口`(`33061`/`63791`)。
|
||||
|
||||
|
||||
@ -176,7 +176,7 @@ uni-app/src/
|
||||
| 想做什么 | 去哪里找代码 |
|
||||
|---------|------------|
|
||||
| 加后台管理页面 | 后端 `app/adminapi/controller/{业务}/` + `route/xxx.php`;前端 `admin/src/pages/` + `admin/src/api/` |
|
||||
| 加用户端页面 | 后端 `app/api/controller/v1/` + `route/v1.php`;前端 `uni-app/src/pages/` + `uni-app/src/api/` |
|
||||
| 加用户端页面 | 核心页面:后端 `app/api/controller/v1/` + `route/v1.php`;前端 `uni-app/src/pages/` + `uni-app/src/api/`。插件独立页放 `uni-app/src/pages-modules/<标识>/`,用法见 [uniapp-pages.md](../../../crmeb-plugin-dev/references/uniapp-pages.md) |
|
||||
| 改商品/订单逻辑 | 后端 `app/services/product|order/`(CenterProductServices/OrderCenterServices)+ `app/dao/` + `app/model/` |
|
||||
| 新增产品类型 | `modules/product/{type}/`(详参 03-系统商品说明) |
|
||||
| 新增交易模式 | `modules/trade/{type}/`(详参 01-插件机制) |
|
||||
|
||||
@ -261,12 +261,13 @@ class DingTalkChannel extends AbstractNoticeChannel
|
||||
| `channel_name` | varchar(100) | 渠道中文名 |
|
||||
| `title` | varchar(255) | 标题 |
|
||||
| `content` | text | 实际发送内容(变量替换后) |
|
||||
| `variables` | json | 原始业务变量(失败重试用;旧日志可能为空) |
|
||||
| `status` | tinyint | 0=待发送 1=成功 2=失败 |
|
||||
| `error_msg` | varchar(500) | 失败原因 |
|
||||
| `send_time` | int unsigned | 发送时间 |
|
||||
| `created_at` | int unsigned | 创建时间 |
|
||||
|
||||
索引:`mark`/`uid`/`channel_key`/`status`/`send_time`。状态常量定义在 `NoticeLog` model 中:`STATUS_PENDING=0`/`STATUS_SUCCESS=1`/`STATUS_FAILED=2`。
|
||||
索引:`mark`/`uid`/`channel_key`/`status`/`send_time`。状态常量定义在 `NoticeLog` model 中:`STATUS_PENDING=0`/`STATUS_SUCCESS=1`/`STATUS_FAILED=2`。失败重试要求日志已保存 `variables`,经 `Notice::to($uid)->channel($channel_key)->sendMsg(...)` 仅重投该渠道。
|
||||
|
||||
### 4.5 eb_notice —— 站内信内容表
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
alwaysApply: false
|
||||
description: 分销返佣业务参考 - 返佣层级结构与计算规则说明;当前返佣链路由 OrderPaySuccess 等事件与 distribution 模块监听器接入,历史流程仅供迁移定位
|
||||
---
|
||||
> **⚠️ 版本说明**:当前返佣链路已中台化——订单支付成功由 `app/services/order/OrderSuccessServices.php` 触发 `OrderPaySuccess` 事件(基于 `eb_order_center` 中台表);`modules/distribution/listener/` 下监听器负责订单快照、佣金发放和用户扩展,但 **distribution 模块未随当前版本提供**,表中相关路径为规划结构,实际以自身二开实现为准。本文保留的旧流程只供迁移定位,当前字段与行为以代码为准。
|
||||
> **版本说明**:分销模块在 `backend/modules/distribution/`。订单支付成功由 `app/services/order/OrderSuccessServices.php` 触发 `OrderPaySuccess`,模块 `event.php` 把快照、支付和返佣接到对应监听器。下文若与代码不一致,以代码为准。
|
||||
|
||||
## 二、核心架构
|
||||
|
||||
@ -26,16 +26,18 @@ description: 分销返佣业务参考 - 返佣层级结构与计算规则说明
|
||||
|
||||
| 服务文件 | 职责 | 关键方法 |
|
||||
|---------|------|---------|
|
||||
| [UserBrokerageServices.php](../../../../../backend/app/services/user/UserBrokerageServices.php) | 用户佣金管理 | `brokerageIncome()`、`orderRefundBrokerageBack()` |
|
||||
| `modules/distribution/listener/OrderCreateSnapshotListener.php`(未随版本提供) | 订单创建后写入分销快照 | `OrderCreateAfter` |
|
||||
| `modules/distribution/listener/OrderPaidListener.php`(未随版本提供) | 支付成功后的分销处理 | `OrderPaySuccess` |
|
||||
| `modules/distribution/listener/OrderSpreadBrokerageListener.php`(未随版本提供) | 返佣规则与佣金分配 | `OrderSpreadBrokerage` |
|
||||
| `app/services/user/UserBrokerageServices` | 佣金服务。`DistributionManageServices` 引用了这个类,仓库里目前没有对应的 php 文件 | 以类内实际方法为准 |
|
||||
| `backend/modules/distribution/listener/OrderCreateSnapshotListener.php` | 订单创建后的分销快照 | `OrderCreateAfter` |
|
||||
| `backend/modules/distribution/listener/OrderPaidListener.php` | 支付成功后的分销处理 | `OrderPaySuccess` |
|
||||
| `backend/modules/distribution/listener/OrderSpreadBrokerageListener.php` | 返佣规则与佣金分配 | `OrderSpreadBrokerage` |
|
||||
|
||||
---
|
||||
|
||||
## 三、佣金类型配置
|
||||
|
||||
在 `UserBrokerageServices::$incomeData` 中定义:
|
||||
下面这张表是历史佣金类型。`app/services/user/UserBrokerageServices.php` 当前不在仓库里,不要为了补上这张表去新建核心类。佣金逻辑在 `backend/modules/distribution/`。
|
||||
|
||||
在旧的 `UserBrokerageServices::$incomeData` 中曾这样定义:
|
||||
|
||||
| 类型标识 | title | type | pm | 说明 |
|
||||
|---------|-------|------|-----|------|
|
||||
|
||||
@ -158,13 +158,15 @@ class ToolListPage extends BaseSchemaPageProvider
|
||||
|
||||
### 4.0 统计卡片(cards)与状态分类头(tabs)
|
||||
|
||||
**统计卡片**(渲染在页面顶部,数据由 `list()` 响应的 `summary` 键下发,对象格式 `{field: value}`):
|
||||
**统计卡片**(默认渲染在搜索栏之前;页面声明 `search_first => true` 时搜索栏在上、统计卡在下。数据由 `list()` 响应的 `summary` 键下发,对象格式 `{field: value}`):
|
||||
|
||||
```php
|
||||
'cards' => [
|
||||
['field' => 'sum_price', 'label' => '充值总金额', 'type' => 'amount'], // type: text/number/amount(缺省 text)
|
||||
['field' => 'sum_price', 'label' => '充值总金额', 'type' => 'amount', 'icon' => 'el-icon-yuan', 'theme' => 'primary'],
|
||||
['field' => 'count', 'label' => '充值笔数', 'type' => 'number'],
|
||||
],
|
||||
// icon 可选。任一项声明 icon 后,每项单独成卡(左侧图标、右侧数值与标题,对齐订单中台统计卡)。
|
||||
// theme 可选:primary / success / warning / danger,缺省 primary。未声明 icon 时仍在同一张卡片内并排展示。
|
||||
// list() 返回:['list' => [...], 'count' => n, 'summary' => ['sum_price' => 123.45, 'count' => 88]]
|
||||
```
|
||||
|
||||
@ -264,6 +266,8 @@ visible 支持的 op:`eq` / `neq` / `gt` / `lt` / `gte` / `lte`(数值比较
|
||||
| drawer_form | 右侧抽屉 form-create 表单,提交调 save | `form()` + `save()` |
|
||||
| drawer_detail | 右侧抽屉详情描述列表 | `detail()` |
|
||||
| confirm_action | 确认弹窗(`confirm` 文案)→ 调 action | `action()` |
|
||||
|
||||
`action()` 也可先返回确认指令,由列表页弹窗后再提交:`['refresh' => false, 'confirm' => ['message' => '确认文案', 'payload' => ['id' => 1, 'confirmed' => 1]]]`。确认后的请求会把 `payload` 合并进原参数。未返回 `confirm` 时行为不变。
|
||||
| direct_action | 直接调 action | `action()` |
|
||||
| navigate | 跳转(`path` 支持 `:字段` 占位替换行数据) | — |
|
||||
|
||||
|
||||
@ -80,8 +80,7 @@ mark 在调度时经 `Str::camel()` 归一,**任务标识一律使用驼峰命
|
||||
| productReplay | 订单商品自动好评 | `OrderCenterServices::autoComment()` |
|
||||
| clearPoster | 清除昨日海报 | `SystemAttachmentServices::emptyYesterdayAttachment()` |
|
||||
| autoInvoice | 自动开具/冲红电子发票 | `OrderInvoiceServices::autoInvoice()/autoInvoiceRed()` |
|
||||
| refundRecover | 退款一致性恢复 | `RefundOrderServices::recover(100)` |
|
||||
| paymentRecover | 微信支付一致性恢复 | `PayRecordServices::recover()` |
|
||||
| refundRecover | 退款一致性恢复 | `RefundOrderServices::recover(100)`。只处理售后退款,不查支付合同 |
|
||||
| customTimer | 自定义定时任务 | `eval` 管理员自定义代码(仅 debug + 超管 + `filesystem.password` + 危险词校验,见 `SystemCrontabController::saveTimer()`) |
|
||||
| noticeUnpaidOrder | 未支付订单提醒 | 触发 `UnpaidOrderNotice` 事件 |
|
||||
| noticeMemberExpire | 会员到期提醒 | 预留 |
|
||||
|
||||
@ -68,7 +68,7 @@ AfterSaleServices::refund
|
||||
|
||||
幂等设计要点:
|
||||
|
||||
- `PaymentDispatch::replay` 保证 `OrderRefundSuccess` 事件重放安全;触发失败转待办 `recoverSuccessEvents` 补偿。
|
||||
- `OrderRefundSuccess` 在资金事务外直接 `event()`。触发失败把效果记回待办,由 `recoverSuccessEvents` 再投递。
|
||||
- Handler 中的资金/资源副作用一律经 `RefundEffectContextInterface::once()` 登记去重,禁止直接裸写。
|
||||
- 中台 `refund_status`(0 无 / 1 申请中 / 2 已退)由 Adapter `resolveRefundStatus` 推导,退款完成时 `syncStatus` 同步。
|
||||
|
||||
|
||||
@ -68,14 +68,18 @@ Db::commit();
|
||||
核心服务:[OrderSuccessServices.php](../../../../backend/app/services/order/OrderSuccessServices.php)::`paySuccess($orderInfo, $paytype, $other)`,支付回调(同步/异步)与 0 元支付共同汇聚于此:
|
||||
|
||||
```
|
||||
paySuccess
|
||||
├─ PayRecordServices::complete() // 支付单幂等闭环(校验 pay_status/终态)
|
||||
├─ OrderCenterServices::syncStatus( // 中台:processing + pay_status=1 + 交易号
|
||||
│ 'processing', [pay_status, pay_time, extra_info.trade_no...])
|
||||
├─ event('ActivityDataProxy', ...) // 拼团单经代理事件建团(app 不直接引 modules)
|
||||
├─ event('OrderPayLottery', ...) // 抽奖旁路
|
||||
├─ event('OrderPaySuccess', [$orderInfo]) // ★ 各产品类型/分销监听的主事件
|
||||
└─ event('OutPush', ...) // 外部推送
|
||||
paySuccess(一次事务)
|
||||
├─ 行锁订单;已支付直接返回
|
||||
├─ 已取消且带微信流水:PayRecordServices::compensateCanceled()
|
||||
├─ validate();余额支付在本次事务扣款
|
||||
├─ 活动 onOrderPay()
|
||||
├─ OrderCenterServices::syncStatus('processing', ...)
|
||||
└─ 有微信流水时 markCompleted()
|
||||
事务提交后
|
||||
├─ 产品类型 fulfillOrder(),再活动 afterOrderPay()
|
||||
├─ event('OrderPayLottery', ...)
|
||||
├─ event('OrderPaySuccess', [$orderInfo])
|
||||
└─ event('OutPush', ...)
|
||||
```
|
||||
|
||||
- **`OrderPaySuccess` 的 `$orderInfo` 是中台订单数组**(`order_id` 字段即 order_no)。各模块监听器先校验 `product_type`/`source_type` 防误触,再幂等推进自己的扩展表(自动履约型在此调 `Handler::afterPay()`;实物推进 `pending_shipped`)。
|
||||
@ -99,4 +103,4 @@ paySuccess
|
||||
- [ ] 活动单:限购校验生效;`afterOrderCreate` 后置数据(团记录等)落库
|
||||
- [ ] 支付成功:中台 `processing` + `pay_status=1`;扩展表推进;返佣/积分流水正确
|
||||
- [ ] 0 元支付:`payType='free'` 闭环,`paySuccess` 正常触发
|
||||
- [ ] 支付回调重复通知:`PayRecordServices::complete()` 幂等,不重复推进
|
||||
- [ ] 支付回调重复通知:`paySuccess` 看到已支付直接返回,不重复推进
|
||||
|
||||
@ -50,7 +50,7 @@ public function advance(string $orderNo, string $extStatus, array $extra = []):
|
||||
| 阶段 | 事件 | 触发位置(app 层) | 模块监听器典型职责 |
|
||||
| --- | --- | --- | --- |
|
||||
| 创建后 | `OrderCreateAfter` | [CenterOrderController.php](../../../../backend/app/api/controller/v1/order/CenterOrderController.php)(create 末尾) | 下单通知;调度超时取消;分销快照 |
|
||||
| 支付成功 | `OrderPaySuccess` | [OrderSuccessServices.php](../../../../backend/app/services/order/OrderSuccessServices.php)(`paySuccess` 内,先 `syncStatus('processing')` 再触发) | 推进扩展表待发货/自动履约;返佣;拼团推进;支付通知 |
|
||||
| 支付成功 | `OrderPaySuccess` | [OrderSuccessServices.php](../../../../backend/app/services/order/OrderSuccessServices.php)(`paySuccess` 内,先 `syncStatus('processing')` 再触发) | 推进扩展表待发货/自动履约;返佣;支付通知。拼团成团不走这个事件,由 `CombinationActivityHandler::afterOrderPay` 直接处理 |
|
||||
| 支付成功(旁路) | `OrderPayLottery`、`OrderPaySuccessCustomerNotice`、`OutPush` | 同上及 [app/jobs/OrderJob.php](../../../../backend/app/jobs/OrderJob.php) | 抽奖次数、客服/模板消息、外部推送 |
|
||||
| 发货 | `OrderDelivery` | [OrderDeliveryServices.php](../../../../backend/app/services/order/OrderDeliveryServices.php) | 推进待收货;发货通知;微信发货管理 |
|
||||
| 收货 | `OrderTake` | [OrderTakeServices.php](../../../../backend/app/services/order/OrderTakeServices.php)(`takeOrder`) | 推进待评价/待安装;收货奖励(返佣/积分/经验在服务层统一处理) |
|
||||
@ -93,7 +93,7 @@ public function advance(string $orderNo, string $extStatus, array $extra = []):
|
||||
| --- | --- |
|
||||
| 中台与扩展表状态不一致 | 详情接口会经 Adapter 实时重新推导;不一致通常是推进时漏调 `advance()` 直接改了扩展表,或 `defineStatusTree` 未定义该节点 |
|
||||
| 支付后状态没推进 | 1) `OrderSuccessServices::paySuccess` 是否触发 `OrderPaySuccess`;2) 模块监听器是否校验了 `product_type` 导致被过滤;3) 监听器幂等短路条件是否过宽 |
|
||||
| 订单卡在待支付但钱已扣 | 查 `PayRecordServices::complete()` 幂等回调与 `paySuccess` 链路日志(支付回调可能异步重试) |
|
||||
| 订单卡在待支付但钱已扣 | 查对应业务的 `paySuccess` 日志。付款通知按支付记录上的 `business_type` 进入核心服务,不再由定时任务查单补记 |
|
||||
| 取消后库存没回滚 | 用户取消走 `OrderCancel` 事件、超时取消走 `UnpaidOrderCancelJob` 直调适配器,两条路径最终都应经过 `OrderLifecycleServices::handleCancel()`,确认监听器/Job 是否漏接 |
|
||||
| 详情无进度条/按钮 | Adapter 的 `getProgress()`/状态树按钮是否实现;中台组装是否调用 `getProgressFromHandler` |
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: crmeb-plugin-dev
|
||||
description: 创建和修改 CRMEB 可安装插件、第三方驱动与 backend/modules 业务模块。覆盖 plugin.json 清单、Installer 生命周期、菜单种子、消息通知与渠道种子、DIY 声明、打包升级、admin UMD 资源,以及产品类型、交易模式、营销观察者、渠道、分销模块的领域归属、Handler 注册、事件、路由、定时任务、数据分层与前端契约;含插件向 MCP 服务贡献工具(McpToolInterface 契约、McpToolsRegister 事件)。凡涉及插件管理或模块业务行为均适用。
|
||||
description: 创建和修改 CRMEB 可安装插件、第三方驱动与 backend/modules 业务模块。选定类型时先读 references/catalog/00-总览-类型路由与边界.md,再读对应 01-驱动或 02-模块分册。覆盖 plugin.json 清单、Installer 生命周期、菜单种子、消息通知与渠道种子、DIY 声明、打包升级、admin UMD 资源、uni-app pages-modules 独立页放入后的跳转与装修链接,以及产品类型、交易模式、营销观察者、渠道、分销模块的领域归属、Handler 注册、事件、路由、定时任务、数据分层与前端契约;含插件向 MCP 服务贡献工具(McpToolInterface 契约、McpToolsRegister 事件)。凡涉及插件管理或模块业务行为均适用。
|
||||
---
|
||||
|
||||
# CRMEB 插件开发
|
||||
@ -9,11 +9,14 @@ description: 创建和修改 CRMEB 可安装插件、第三方驱动与 backend/
|
||||
|
||||
## 先确定插件类型
|
||||
|
||||
开发插件或驱动前,先读 [类型总览](references/catalog/00-总览-类型路由与边界.md),按其中的类型表打开对应 `01-驱动-*` 或 `02-模块-*` 分册,再写代码。
|
||||
|
||||
- 业务模块插件:放在 `backend/modules/{category}/{name}/`,或采用现有的扁平结构 `backend/modules/{name}/`。先读 [references/domain-selection.md](references/domain-selection.md) 确定领域归属(product/trade/marketing/channel/agent),再读 [references/registration-and-contracts.md](references/registration-and-contracts.md) 了解注册中心、事件、路由、数据层、定时任务与前端契约。
|
||||
- 插件需要让 AI 客户端通过 `/api/mcp` 调用本插件能力(新增 MCP 工具、覆盖内置工具)时,阅读 [references/mcp-tools.md](references/mcp-tools.md)。
|
||||
- 插件需要发送站内信/短信/邮件/机器人等消息通知,或注册自有消息渠道时,阅读 [../crmeb-dev/references/10-消息通知/notice.md](../crmeb-dev/references/10-消息通知/notice.md)(Notice 门面、`getNoticeDefinitions()` / `getNoticeChannelDefinitions()` 种子与渠道契约)。
|
||||
- 第三方驱动插件:放在 `backend/driver/{category}/{name}/`,其中 category 必须是驱动发现服务已支持的类别。
|
||||
- Admin 扩展资源:需要协调后端插件生命周期与 Vue 管理端 UMD 产物,阅读 [references/admin-extension.md](references/admin-extension.md)。
|
||||
- 用户端独立页面:页面放进 `frontend/uni-app/src/pages-modules/<标识>/` 之后,跳转、装修链接、参数和验收见 [references/uniapp-pages.md](references/uniapp-pages.md)。
|
||||
|
||||
创建清单、修改安装/卸载行为、新增菜单或数据、打包升级版本前,阅读 [references/manifest-and-lifecycle.md](references/manifest-and-lifecycle.md)。
|
||||
|
||||
@ -31,7 +34,7 @@ description: 创建和修改 CRMEB 可安装插件、第三方驱动与 backend/
|
||||
4. 确保 `plugin.json` 能通过 `PluginManifest::fromFile()`:`key`、`name`、`type`、`version` 和 `path` 必须是有效字符串,声明路径必须与实际目录一致。模块 key 遵循相近模块约定(如 `product_cardkey`、`activity_seckill` 下划线格式),`plugin.json`、Installer、事件、菜单权限和前端扩展 key 必须一致。
|
||||
5. `Installer::getKey()` 必须兼容现有记录使用的清单旧 key/path。当旧 key 可能产生歧义时,管理边界优先使用 canonical ID。
|
||||
6. 安装、种子写入、菜单插入、启用和禁用操作必须幂等;基类 `onEnable()` 默认会重新执行 `seed()`,种子逻辑必须经得起重复调用;清理范围只能覆盖本插件拥有的资源。
|
||||
7. 插件提供 uni-app 独立页面时,用基类 `ensurePageLinkCategory()` / `seedPageLinks()` 把页面注册进 DIY 链接选择器,卸载用 `cleanPageLinks()` 清理;插件自带 DIY 组件时在清单声明 `diy.components`(见 manifest-and-lifecycle.md 的 DIY 两节)。
|
||||
7. 插件提供 uni-app 独立页面时,源码放 `frontend/uni-app/src/pages-modules/<标识>/`,跳转与装修都使用该模块 `routes.js` 的路径(见 [references/uniapp-pages.md](references/uniapp-pages.md))。Installer 用基类 `ensurePageLinkCategory()` / `seedPageLinks()` 把同一条路径注册进 DIY 链接选择器,卸载用 `cleanPageLinks()` 清理;插件自带 DIY 组件时在清单声明 `diy.components`(见 manifest-and-lifecycle.md 的 DIY 两节)。
|
||||
8. 依赖关系写入清单 `require`,不得用分散的运行时判断重复实现。
|
||||
9. 使用插件间共享表时,覆写 `sharedTables()` 声明准入,卸载用 `dropSharedTableIfUnclaimed()` 仲裁删表,`cleanTable()` 只按归属条件清理自身数据行(表需同步声明进 `getOwnedTables()`);协议细则见 [.crmeb/rules/架构规范详解.md](../../rules/架构规范详解.md)「插件间共享表准入协议」。
|
||||
10. 保持前端契约数据驱动:商品 `display_sections`、订单 `page_config`、订单按钮/状态和扩展载荷,应让 admin/uni-app 无需增加中央类型分支即可渲染新能力。新增产品类型或交易模式时,优先向现有前端契约添加数据,不修改核心前端。
|
||||
|
||||
@ -0,0 +1,243 @@
|
||||
# CRMEB 插件与驱动开发总览(Agent 先读本篇)
|
||||
|
||||
核对日期:2026-11-06,按当前仓库代码逐条核对。代码与本文冲突时以代码为准,并回头修正本文。
|
||||
|
||||
本篇回答三件事:做哪一类、所有类型共用的流程、什么时候必须停下来问用户。类型专属内容在 `01-驱动-*` / `02-模块-*` 分册,分册不再重复本篇内容。
|
||||
|
||||
强制条款真源:[插件开发规范](../../../../rules/插件开发规范.md)、[架构规范](../../../../rules/架构规范.md)。本手册只写“怎么做”。
|
||||
|
||||
## 1. 选类型
|
||||
|
||||
| 需求 | 落位 | 分册 |
|
||||
| --- | --- | --- |
|
||||
| 接一个支付通道 | `backend/driver/pay/{Dir}/` | [01-驱动-pay-支付](01-驱动-pay-支付.md) |
|
||||
| 接一个短信服务商 | `backend/driver/sms/{Dir}/` | [01-驱动-sms-短信](01-驱动-sms-短信.md) |
|
||||
| 接一个邮件服务商 | `backend/driver/email/{Dir}/` | [01-驱动-email-邮件](01-驱动-email-邮件.md) |
|
||||
| 接一个对象存储 | `backend/driver/upload/{Dir}/` | [01-驱动-upload-上传存储](01-驱动-upload-上传存储.md) |
|
||||
| 接一个物流轨迹查询 | `backend/driver/logistics/{Dir}/` | [01-驱动-logistics-物流查询](01-驱动-logistics-物流查询.md) |
|
||||
| 接一个电子面单服务商 | `backend/driver/express/{Dir}/` + `backend/driver/logistics/{Dir}/` | [01-驱动-express-电子面单](01-驱动-express-电子面单.md) |
|
||||
| 接一个电子发票服务商 | `backend/driver/invoice/{Dir}/` | [01-驱动-invoice-发票](01-驱动-invoice-发票.md) |
|
||||
| 接一个群机器人 | `backend/driver/bot/{Dir}/` | [01-驱动-bot-机器人通知](01-驱动-bot-机器人通知.md) |
|
||||
| 接一个云打印机 | `backend/driver/printer/{Dir}/` | [01-驱动-printer-云打印](01-驱动-printer-云打印.md) |
|
||||
| 接一个商品采集源 | `backend/driver/copyproduct/{Dir}/` | [01-驱动-copyproduct-商品采集](01-驱动-copyproduct-商品采集.md) |
|
||||
| 新商品形态(卖什么) | `backend/modules/product/{name}/` | [02-模块-product-产品类型](02-模块-product-产品类型.md) |
|
||||
| 新成交方式(怎么卖) | `backend/modules/trade/{name}/` | [02-模块-trade-交易模式](02-模块-trade-交易模式.md) |
|
||||
| 观察行为发奖励、独立运营活动 | `backend/modules/marketing/{name}/` | [02-模块-marketing-营销玩法](02-模块-marketing-营销玩法.md) |
|
||||
| 新登录端 / 渠道 SDK | `backend/modules/channel/{name}/` | [02-模块-channel-用户渠道](02-模块-channel-用户渠道.md) |
|
||||
| 分销相关 | `backend/modules/distribution/` | [02-模块-distribution-分销](02-模块-distribution-分销.md) |
|
||||
|
||||
判定顺序:
|
||||
|
||||
1. 只是“和第三方说话”,业务规则不变 → 驱动(`driver/`)。
|
||||
2. 改变商品的库存、履约、售后形态 → product。
|
||||
3. 改变活动价、购买资格、限购,失败要阻断下单 → trade。
|
||||
4. 去掉它订单语义不变,只是不再发奖 → marketing。
|
||||
5. 仍不确定 → 读 [domain-selection.md](../domain-selection.md),再不确定就问用户。
|
||||
|
||||
驱动类别只有上表 10 种。新增驱动类别需要改 `crmeb/` 和 `app/`,超出插件范围,先问用户。
|
||||
|
||||
## 2. 停下来问用户的情形
|
||||
|
||||
插件必须零侵入:`backend/app/`、`backend/crmeb/`、核心前端、`backend/public/install/`、`composer.json`、`backend/config/`、`.env` 一律不改。下面这些已核实的情形,靠插件目录做不完,必须先向用户说明并取得同意,不要自行修改核心:
|
||||
|
||||
| 类型 | 核心里写死的位置 | 后果 |
|
||||
| --- | --- | --- |
|
||||
| pay | `eb_pay_channel` 没有新增渠道的接口;`app/services/pay/PayServices.php` 按 `weixin`/`alipay`/`allinpay` 分支;`frontend/uni-app/src/components/payment/index.vue` 按 `channel_key` 的 `switch` 没有 `default` | 只装驱动,后台“支付方式”看不到,端上调不起 |
|
||||
| logistics | `app/services/shipping/ExpressServices.php` 的 `resolveEnabledQueryDriver()` 只放行 `aliyun_express`、`yihaotong`,`query()` 用 `switch` 解析返回 | 新物流查询驱动启用后抛“物流查询驱动不支持” |
|
||||
| printer | `app/services/driver/printer/SystemTicketServices.php` 只放行 `YiLianYun`、`FeiEYun`,并按名字拼配置和小票内容 | 新打印驱动无法出票 |
|
||||
| invoice | 调用方固定 `app()->make(Invoice::class, [yihaotong_config()])`,`Invoice::getDefaultDriver()` 返回 `yihaotong` | 新发票驱动不会被调用 |
|
||||
| bot | 通知渠道 `DingTalkChannel` 固定调 `Bot::sendMarkdown('ding_talk', ...)`,`EntWechatChannel` 直接发 HTTP,没有飞书或自定义机器人渠道 | 新机器人驱动没有调用方 |
|
||||
| express | `crmeb\services\logistics\Express` 的命名空间固定为 `\driver\logistics\` | 面单驱动的类必须同时放在 `driver/logistics/{Dir}/`,见分册 |
|
||||
|
||||
短信、邮件、上传存储、商品采集按 `driver_key` 动态实例化,新驱动可以零侵入接入(各分册列出了少量仍按 key 写死的分支)。
|
||||
|
||||
## 3. 驱动通用机制(所有 01 分册共用)
|
||||
|
||||
### 3.1 一条链路
|
||||
|
||||
```text
|
||||
driver/{type}/{Dir}/plugin.json
|
||||
→ 后台安装:ManifestDriverInstaller(无 Installer.php 时自动使用)
|
||||
→ BaseModuleInstaller::installDriverRecord() 写一行 eb_driver
|
||||
→ 运行时 DriverRegistry::scan($type) 只读 eb_driver,并按 owner_plugin_id 过滤未启用插件
|
||||
→ 业务 Service 取配置 → 门面 new Xxx($driverKey, $config) → Str::studly($driverKey) 拼类名 → 驱动类
|
||||
```
|
||||
|
||||
运行时不扫目录。目录拷进去但没走安装,后台看不到,`scan()` 也拿不到。
|
||||
|
||||
### 3.2 必须对齐的四个名字
|
||||
|
||||
| 名字 | 规则 | 不对齐的表现 |
|
||||
| --- | --- | --- |
|
||||
| 目录名 `{Dir}` | 必须等于 `Str::studly(driver_key)`。例:`ali_pay` → `AliPay`,`aliyun_dm` → `AliyunDm` | 门面抛 `Driver [xxx] not supported.` |
|
||||
| 命名空间 | `driver\{type}\{Dir}`,类名 `Driver` | 同上;Linux 下 PSR-4 加载失败 |
|
||||
| `plugin.json` 的 `path` | `driver/{type}/{Dir}`,必须与真实目录完全一致 | 安装报“清单 path 与实际目录不一致” |
|
||||
| 顶层 `key` 与 `driver.driver_key` | 两者写成同一个值 | 不报错,但复合标识 `driver:{type}:{driver_key}` 与 `key` 不同,排查绕路 |
|
||||
|
||||
`Str::studly()` 规则:按 `_`、`-`、空格切分,每段首字母大写后拼接。可以用下面的命令确认:
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; echo \think\helper\Str::studly("your_key"), PHP_EOL;'
|
||||
```
|
||||
|
||||
反例,不要模仿:`driver/sms/yunxin/`(目录小写,命名空间却是 `Yunxin`,Linux 下会加载失败);`driver/bot/DingTalk/` 的 `driver_key` 是 `dingtalk`,`Str::studly` 得到 `Dingtalk`,只能靠调用方传 `ding_talk` 才能找到。
|
||||
|
||||
### 3.3 plugin.json 顶层
|
||||
|
||||
`PluginManifest::fromFile()` 强校验 `key`、`name`、`type`、`version`、`path` 五个非空字符串,驱动还必须有 `driver.driver_key`。
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_key",
|
||||
"name": "显示名",
|
||||
"description": "一句话说明",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/{type}/YourKey",
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": { "driver_key": "your_key", "name": "显示名" }
|
||||
}
|
||||
```
|
||||
|
||||
- `version` 固定写 `1.0.0`。项目处于首版开发阶段,规则要求所有插件版本统一 `1.0.0`、不写 `upgrades/` 脚本(见 [架构规范「当前发布阶段」](../../../../rules/架构规范.md))。用户明确进入发布后维护阶段时才递增版本。
|
||||
- `default_install`:`true` 表示随系统默认安装。需要用户自备密钥的第三方驱动写 `false` 或不写。
|
||||
|
||||
### 3.4 driver 子对象写进 eb_driver 的字段
|
||||
|
||||
只有下面这些字段会落库,其他字段只在安装期可读(个别类型的 Service 会回读 `plugin.json`,分册会注明):
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `driver_key` | 必填,`(driver_type, driver_key)` 唯一 |
|
||||
| `name` | 后台显示名,缺省用顶层 `name` |
|
||||
| `class_name` | 缺省 `Driver`,不要改 |
|
||||
| `icon`、`sort` | 展示与排序,`sort` 越大越靠前 |
|
||||
| `is_builtin` | 见下方启用状态 |
|
||||
| `capabilities` | 能力声明,数组或对象,按类型约定 |
|
||||
| `config_fields` | 后台动态表单 |
|
||||
| `help_doc` | `{"title": "", "steps": [], "link": ""}` |
|
||||
| `domain_template`、`bucket_fields`、`thumb_watermark_config` | 仅 upload |
|
||||
| `platforms` | 仅 copyproduct |
|
||||
|
||||
安装后的启用状态(`resolveDriverInstallStatus()`):`is_builtin` 不为真 → 0(关闭);为真且类型是 `sms`/`email`/`copyproduct`/`logistics` → 同类型已有启用驱动时为 0,否则为 1;其他类型为真 → 1。第三方驱动一律不写 `is_builtin`,装完由管理员在后台启用。
|
||||
|
||||
重装用 `ON DUPLICATE KEY UPDATE` 覆盖元数据,不覆盖 `status`。
|
||||
|
||||
### 3.5 config_fields 写法
|
||||
|
||||
```json
|
||||
{"field": "your_key_app_id", "label": "AppID", "type": "text", "required": true, "group": "账号", "tip": "说明文字"}
|
||||
```
|
||||
|
||||
- `type` 现役取值:`text`、`password`、`textarea`、`radio`、`select`、`switch`、`upload`。`radio`/`select` 配 `options: [{"value": "", "label": ""}]`。
|
||||
- 条件显示:`"show_if": {"field": "另一个字段", "value": "取值"}`。
|
||||
- 默认值:`default`(个别现役驱动写 `value`,新驱动统一用 `default`)。
|
||||
- 字段名带 `driver_key` 前缀,避免与其他驱动混淆。
|
||||
- 密钥类字段用 `password`。清单里只写字段定义,绝不写真实密钥。
|
||||
- 管理员保存的值存在 `eb_driver.config_fields` 每项的 `value` 里(pay 例外,见分册)。
|
||||
|
||||
### 3.6 Installer.php
|
||||
|
||||
纯驱动不需要写。框架在找不到 `Installer.php` 时自动用 `ManifestDriverInstaller`。只有需要建自有表时才写,写法照 `backend/driver/sms/yunxin/Installer.php`:继承 `crmeb\module\BaseModuleInstaller`,`getKey()` 返回 `driver:{type}:{driver_key}`,`getType()` 返回 `driver`,命名空间与 `Driver.php` 相同。
|
||||
|
||||
### 3.7 第三方 SDK
|
||||
|
||||
放在 `driver/{type}/{Dir}/extend/` 下,命名空间 `driver\{type}\{Dir}\extend\...`,由根 autoload 的 `driver\` → `driver/` 映射自动加载(参考 `backend/driver/upload/Cos/extend/cos/`)。不改 `composer.json`,不往 `vendor/` 加包。宿主 `vendor/` 已有的包(如 `guzzlehttp/guzzle`)可以直接用。
|
||||
|
||||
### 3.8 驱动代码边界
|
||||
|
||||
- 驱动只负责和第三方通信:组请求、签名验签、解析返回。
|
||||
- 不调用 `app\services\*`、不 `use modules\*`、不写业务表。
|
||||
- 失败时调用 `$this->setError('原因')` 并返回 `false`(`BaseStorage` 子类可用,返回值就是 `false`)。
|
||||
- 不打印密钥、完整请求签名或用户手机号到日志。
|
||||
|
||||
## 4. 模块通用机制(所有 02 分册共用)
|
||||
|
||||
### 4.1 启动与路由
|
||||
|
||||
- `crmeb\plugin\ModuleRegistry` 扫描 `modules/*/App.php`、`modules/*/*/App.php`、`modules/*/*/*/App.php`。目录下有 `plugin.json` 时,只有插件已安装且启用才调用 `App::register()` / `App::boot()`。
|
||||
- `App::register(\think\App $app)`:向注册中心登记 Handler、Adapter、定时任务。
|
||||
- `App::boot(\think\App $app)`:加载本模块 `event.php`(`$app->loadEvent(include __DIR__ . '/event.php')`)。
|
||||
- 路由文件:`route/admin.php`(挂在 `/adminapi` 下)、`route/user.php`(挂在 `/api` 下),由 `ModuleRouteLoader` 只扫一级和二级目录,未启用模块的路由不加载。
|
||||
- 控制器放 `controller/admin/`、`controller/user/v1/`,类名以 `Controller` 结尾。
|
||||
- 命名空间等于目录路径:`modules\{category}\{name}\...`。
|
||||
|
||||
### 4.2 plugin.json 的 type
|
||||
|
||||
| 落位 | `type` | `key` 现役命名 |
|
||||
| --- | --- | --- |
|
||||
| `modules/product/{name}` | `product_type` | `product_{name}` |
|
||||
| `modules/trade/{name}` | `mode` | `activity_{name}` |
|
||||
| `modules/marketing/{name}` | `marketing` | 各不相同,新模块建议 `marketing_{name}` |
|
||||
| `modules/channel/{name}` | `channel` | `channel/{name}` |
|
||||
| `modules/{name}` | `service` 等 | `{name}` |
|
||||
|
||||
`plugin.json` 的 `key`、Installer `getKey()`、菜单、权限、前端扩展 key 必须一致。
|
||||
|
||||
### 4.3 脚手架
|
||||
|
||||
```bash
|
||||
cd backend && php think make:module demo --label=演示模块 --type=marketing
|
||||
```
|
||||
|
||||
它只生成扁平目录 `modules/demo/`,命名空间 `modules\demo`,`path` 为 `modules/demo`。要放进 `modules/{category}/` 时,把目录移进去后手工改三处:所有文件的 `namespace`、`plugin.json` 的 `path`、`plugin.json` 和 Installer 的 `type`(脚手架写的 `product`/`trade` 要改成 `product_type`/`mode`)。更省事的做法是复制分册里指定的参考模块。
|
||||
|
||||
### 4.4 Installer 要点
|
||||
|
||||
继承 `crmeb\module\BaseModuleInstaller`,必须实现 `getKey()`、`getName()`、`getType()`、`runMigrate()`、`rollbackMigrate()`、`getMenuConfig()`。可选覆写 `seed()`、`cleanData()`、`onEnable()`、`onDisable()`(覆写必须调 `parent::`)、`getVersion()`(首版阶段返回 `1.0.0`)。
|
||||
|
||||
- 自有表名 `eb_{插件标识}_*`,在 `runMigrate()` 里 `CREATE TABLE IF NOT EXISTS`,在 `rollbackMigrate()` 里删除。不对系统表做 DDL。
|
||||
- 菜单只通过 `getMenuConfig()` 声明,挂核心父级用 `parent_key`(如 `core:admin-marketing`),同批子菜单用 `'pid' => '@父菜单名'`。可见菜单 `auth_type=1`,接口权限 `auth_type=2` 且必须有 `api_url` + `methods`,按钮 `auth_type=3` + `unique_auth`。完整说明见 [manifest-and-lifecycle.md](../manifest-and-lifecycle.md)「菜单权限」。
|
||||
- 定时任务:`App::register()` 里 `CrontabTaskRegistry::register($mark, $class)`,Installer `seed()` 幂等写 `system_crontab` 一行(按 `mark` 先查),`cleanData()` 按 `mark` 删。参考 `backend/modules/marketing/signin/Installer.php`。
|
||||
- 所有写操作幂等,安装、重装、启用、禁用、卸载都能重复执行。
|
||||
|
||||
### 4.5 前端
|
||||
|
||||
- 管理端插件页面:源码只放 `frontend/admin/src/pages/plugin/<key>/`,见 [admin-extension.md](../admin-extension.md)。不需要独立前端包时,可以用 PHP 声明后台页。
|
||||
- uni-app 独立页:源码放 `frontend/uni-app/src/pages-modules/<标识>/`。放入后的地址、`routes.js` 跳转、装修链接与验收见 [uniapp-pages.md](../uniapp-pages.md)。Installer 用 `ensurePageLinkCategory()` + `seedPageLinks()` 登记同一条路径;DIY 组件在 `plugin.json` 的 `diy.components` 声明。
|
||||
- 不改核心前端的类型分支。需要展示扩展字段时,优先走 Handler 返回的 `display_sections`、`page_config` 等数据契约。
|
||||
|
||||
## 5. 安装与验证命令
|
||||
|
||||
安装没有命令行入口(`app/command/PluginInstall.php` 只是占位)。两种方式:
|
||||
|
||||
1. 后台“插件管理”页面安装、启用。
|
||||
2. 调接口(需要管理员 token,`plugin_key` 可以写复合标识、`path` 或 `key`):
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://127.0.0.1:8011/adminapi/plugin/install' \
|
||||
-H 'Authori-zation: Bearer <admin token>' \
|
||||
-d 'plugin_key=driver:sms:your_key'
|
||||
# 同组还有 plugin/enable、plugin/disable、plugin/uninstall
|
||||
```
|
||||
|
||||
安装、卸载会写数据库,只在用户确认过的开发库或隔离库上执行,不要在不明来源的库上执行。
|
||||
|
||||
静态校验(不写库,任何时候都可以跑):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/{type}/{Dir}/Driver.php # 语法
|
||||
php think plugin:manifest-check # 清单、复合身份、Installer 元数据一致性
|
||||
php think arch:check # 依赖方向;当前基线已有 2 处严重违规(modules/service/outapi),只看新增
|
||||
php think registry:check # 模块:产品类型/交易模式/订单适配器/售后规则注册一致性
|
||||
php think event:check # 模块:事件注册一致性(当前基线已有 2 个孤儿事件警告)
|
||||
```
|
||||
|
||||
安装后确认驱动可被发现(只读库):
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
$t = "sms"; foreach (crmeb\services\driver\DriverRegistry::scan($t) as $k => $d)
|
||||
echo $k, " => ", crmeb\services\driver\DriverRegistry::getDriverClass($t, $k), " status=", $d["status"], PHP_EOL;'
|
||||
```
|
||||
|
||||
期望看到你的 `driver_key` 和 `driver\{type}\{Dir}\Driver`。看不到时按顺序排查:是否已安装 → 插件是否启用 → `owner_plugin_id` 是否非空。
|
||||
|
||||
## 6. 交付
|
||||
|
||||
- 只交付插件根目录(驱动 `driver/{type}/{Dir}/`,模块 `modules/{category}/{name}/`),以及模块的 `frontend/admin/src/pages/plugin/<key>/`。
|
||||
- `git diff --name-only` 里不应出现第 2 节列出的核心路径。如果用户同意了核心改动,在交付说明里单独列出。
|
||||
- 不提交 `.env`、真实密钥、证书、运行时数据。
|
||||
- 提交信息遵循 [提交规范](../../../../rules/提交规范.md)。
|
||||
@ -0,0 +1,268 @@
|
||||
# 驱动分册 · 机器人通知(bot)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写群机器人/Webhook 通知专属内容。
|
||||
|
||||
## 1. 选型判定
|
||||
|
||||
| 需求 | 落本类型 |
|
||||
| --- | --- |
|
||||
| 向钉钉/企业微信/飞书群机器人发 Webhook 消息 | `backend/driver/bot/{Dir}/` |
|
||||
| 短信、邮件、站内信 | 各自分册 |
|
||||
| 新增「通知渠道」类型(如 Slack) | 驱动可写,但 **app 消息渠道未注册则不会被调用** |
|
||||
|
||||
bot 驱动只做 HTTP POST 到 Webhook;消息模板、变量替换、渠道开关在 `app/services/message/` 与 `eb_notice_template`。
|
||||
|
||||
## 2. 开工前必须告诉用户
|
||||
|
||||
| 渠道 | 是否走 bot 驱动 |
|
||||
| --- | --- |
|
||||
| 钉钉 | **是**:`DingTalkChannel` → `Bot::sendMarkdown('ding_talk', $webhook, ...)` |
|
||||
| 企业微信 | **否**:`EntWechatChannel` 直接用 `Http::postRequest`,未经过 `driver/bot/EntWechat` |
|
||||
| 飞书 | **否**:仓库有 `driver/bot/Feishu/`,但无对应 `NoticeChannel` 实现 |
|
||||
|
||||
因此:新装 `Feishu` 或自定义 bot 驱动,**不会**自动进入「消息通知」配置页,除非用户同意新增 `AbstractNoticeChannel` 子类并注册到通知系统。
|
||||
|
||||
另外 **`driver_key` 与 Bot 调用名不一致**:
|
||||
|
||||
| plugin.json `driver_key` | `Bot::send*` 传入名 | 目录 |
|
||||
| --- | --- | --- |
|
||||
| `dingtalk` | **`ding_talk`**(下划线) | `DingTalk/` |
|
||||
| `ent_wechat` | 渠道未用 Bot | `EntWechat/` |
|
||||
| `feishu` | 无调用方 | `Feishu/` |
|
||||
|
||||
`Str::studly('ding_talk')` → `DingTalk`,与目录一致;若调用方写 `dingtalk` 会找不到类。
|
||||
|
||||
## 3. 文件清单
|
||||
|
||||
```text
|
||||
backend/driver/bot/{Dir}/
|
||||
├── plugin.json
|
||||
└── Driver.php # namespace driver\bot\{Dir}; extends BaseBot implements BotInterface
|
||||
```
|
||||
|
||||
| 组件 | 路径 |
|
||||
| --- | --- |
|
||||
| 基类 | `crmeb\services\bot\BaseBot`(`request()` POST JSON) |
|
||||
| 门面 | `crmeb\services\bot\Bot`(静态 `sendText` / `sendMarkdown` / `sendCard`) |
|
||||
| 接口 | `crmeb\services\bot\BotInterface` |
|
||||
|
||||
现役:
|
||||
|
||||
| 目录 | driver_key | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `DingTalk/` | `dingtalk` | 钉钉自定义机器人 |
|
||||
| `EntWechat/` | `ent_wechat` | 实现存在,通知渠道未用 |
|
||||
| `Feishu/` | `feishu` | 飞书 post/交互格式 |
|
||||
|
||||
**推荐照抄**:`backend/driver/bot/DingTalk/`(最小三方法)。
|
||||
|
||||
## 4. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_bot",
|
||||
"name": "某某群机器人",
|
||||
"description": "Webhook 机器人",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/bot/YourBot",
|
||||
"default_install": false,
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某群机器人",
|
||||
"driver_key": "your_bot",
|
||||
"sort": 100,
|
||||
"capabilities": ["send_text", "send_markdown", "send_card"],
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_bot_webhook",
|
||||
"label": "Webhook 地址",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "配置",
|
||||
"tip": "也可只在通知模板 extra 里配置"
|
||||
}
|
||||
],
|
||||
"help_doc": {
|
||||
"title": "配置说明",
|
||||
"steps": ["在群设置添加自定义机器人", "复制 Webhook", "在通知模板中填写 extra.webhook"],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 钉钉渠道实际 Webhook 来自 **`eb_notice_template.extra.webhook`**(按模板配置),不是必须读 `eb_driver.config_fields`。
|
||||
- `help_doc` 可为对象 `{title, steps, link}` 或链接数组(现役钉钉用数组)。
|
||||
|
||||
## 5. Driver.php 方法签名
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\bot\YourBot;
|
||||
|
||||
use crmeb\services\bot\BaseBot;
|
||||
|
||||
class Driver extends BaseBot
|
||||
{
|
||||
public function sendText(string $content, array $options = []): bool
|
||||
{
|
||||
return $this->request([
|
||||
'msgtype' => 'text',
|
||||
'text' => ['content' => $content],
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendMarkdown(string $title, string $content, array $options = []): bool
|
||||
{
|
||||
// 按平台文档组装 msgtype / markdown 或 post
|
||||
}
|
||||
|
||||
public function sendCard(array $card, array $options = []): bool
|
||||
{
|
||||
// 钉钉 actionCard、飞书 interactive 等
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.1 契约
|
||||
|
||||
| 方法 | 必填 | 返回值 |
|
||||
| --- | --- | --- |
|
||||
| `sendText` | 是 | `bool`,失败时 `BaseBot::request` 返回 false |
|
||||
| `sendMarkdown` | 钉钉渠道需要 | 同上 |
|
||||
| `sendCard` | 否 | 同上 |
|
||||
|
||||
`BaseBot::request()`:向 `$this->webhook` POST JSON;成功条件 `errcode===0` 或 `code===0`(兼容部分网关)。
|
||||
|
||||
Webhook 由调用方注入:
|
||||
|
||||
```php
|
||||
Bot::sendMarkdown('ding_talk', $webhook, $title, $content);
|
||||
// 内部 new Bot($driver); setWebhook($webhook); sendMarkdown(...)
|
||||
```
|
||||
|
||||
### 5.2 平台消息体差异(实现时对照)
|
||||
|
||||
| 平台 | text | markdown |
|
||||
| --- | --- | --- |
|
||||
| 钉钉 | `msgtype:text` | `msgtype:markdown` + `title`/`text` |
|
||||
| 企业微信 | `msgtype:text` | `msgtype:markdown`,标题常合并进 content |
|
||||
| 飞书 | `msg_type:text` | 多为 `msg_type:post` 富文本 |
|
||||
|
||||
EntWechat 渠道在 app 层已写死 markdown 引用块格式,与 `EntWechat/Driver.php` 无关。
|
||||
|
||||
## 6. 配置存储
|
||||
|
||||
| 存储 | 用途 |
|
||||
| --- | --- |
|
||||
| `eb_driver` | 驱动元数据;可选默认 webhook 字段 |
|
||||
| `eb_notice_template.extra` | 渠道级 Webhook:`dingtalk` → `webhook`;企业微信 → `url` |
|
||||
|
||||
驱动内不要持久化 Webhook 到业务表。
|
||||
|
||||
## 7. 调用链
|
||||
|
||||
```text
|
||||
消息通知任务
|
||||
→ Notice 分发 → DingTalkChannel::send(NoticeMessage)
|
||||
→ 读 extra.webhook、渲染模板变量
|
||||
→ Bot::sendMarkdown('ding_talk', $webhook, $title, $content)
|
||||
→ driver\bot\DingTalk\Driver::sendMarkdown
|
||||
→ BaseBot::request
|
||||
|
||||
企业微信
|
||||
→ EntWechatChannel::send → Http::postRequest($url, ...) // 不经过 bot 驱动
|
||||
```
|
||||
|
||||
手动调试(不写库):
|
||||
|
||||
```php
|
||||
\crmeb\services\bot\Bot::sendText('ding_talk', 'https://oapi.dingtalk.com/robot/send?access_token=...', 'ping');
|
||||
```
|
||||
|
||||
## 8. 逐步开发流程
|
||||
|
||||
1. 定 `driver_key` 与目录 `{Dir}`;确认将来调用方传的 studly 名(如 `ding_talk` vs `dingtalk`)。
|
||||
2. 复制 `DingTalk/Driver.php`,改 JSON 结构。
|
||||
3. 编写 `plugin.json`;第三方机器人 `default_install: false`。
|
||||
4. `php think plugin:manifest-check`;安装插件。
|
||||
5. 若需接入通知中心:与用户确认新增 Channel 类、`channelKey()`、模板 extra 字段、注册表配置(改 `app/`)。
|
||||
6. 用真实 Webhook 测 `Bot::sendText` / `sendMarkdown`;钉钉注意关键词/签名校验。
|
||||
|
||||
## 9. 参考实现
|
||||
|
||||
- `driver/bot/DingTalk/Driver.php`:三种 msgtype。
|
||||
- `driver/bot/Feishu/Driver.php`:post 富文本。
|
||||
- `app/services/message/channel/DingTalkChannel.php`:唯一生产调用链。
|
||||
|
||||
## 10. 打包(`.plugin-packages/`)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/bot/YourBot/Driver.php
|
||||
php think plugin:manifest-check
|
||||
zip -r ../.plugin-packages/driver-bot-your_bot-1.0.0.zip driver/bot/YourBot/
|
||||
```
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
- [ ] 四名字对齐;`Bot::send*` 使用的 driver 字符串与 `Str::studly` 结果能解析到目录
|
||||
- [ ] 三方法签名符合 `BotInterface`
|
||||
- [ ] 无业务层 `use`;Webhook 不打印到 Log
|
||||
- [ ] 安装后 `DriverRegistry::scan('bot')` 可见
|
||||
- [ ] **仅驱动**:CLI/脚本调用 Bot 静态方法成功
|
||||
- [ ] **接通知渠道后**:模板开启钉钉、填 webhook,平台通知能收到
|
||||
|
||||
## 12. 常见失败现象
|
||||
|
||||
| 现象 | 原因 |
|
||||
| --- | --- |
|
||||
| `Bot driver [dingtalk] not supported` | 调用应用 `dingtalk` 而非 `ding_talk` |
|
||||
| 驱动安装但通知发不出 | 未建 NoticeChannel;或模板未配 `extra.webhook` |
|
||||
| `钉钉 webhook 未配置` | 模板 extra 空 |
|
||||
| `钉钉机器人返回发送失败` | 关键词不匹配、IP 白名单、签名错误;`request()` 只返回 false |
|
||||
| 企业微信改了 EntWechat 驱动无效 | 渠道不走驱动 |
|
||||
| 飞书驱动无效果 | 无飞书 Channel |
|
||||
|
||||
## 13. 安全与日志
|
||||
|
||||
- Webhook URL 含 access_token,禁止写入提交说明、禁止 `Log::info` 全量 URL。
|
||||
- 失败时 `DingTalkChannel` 抛 `NoticeSendException`,由通知任务记录;驱动层保持返回 bool,不吞异常外的业务逻辑。
|
||||
|
||||
## 14. NoticeChannel 接入要点(需改 app 时)
|
||||
|
||||
若用户要求新机器人走「消息通知」:
|
||||
|
||||
1. 新增 `app/services/message/channel/XxxChannel.php` 继承 `AbstractNoticeChannel`。
|
||||
2. 实现 `channelKey()`(对应模板表开关字段,如 `is_dingtalk`)、`getExtraFields()`(webhook/url)。
|
||||
3. `send()` 内调用 `Bot::sendMarkdown('{studly_name}', $webhook, ...)`,**studly 名与目录一致**。
|
||||
4. 在通知渠道注册表(与现有 DingTalk/EntWechat 同级)登记该类。
|
||||
5. 管理端模板配置页需能编辑 extra 字段(通常已有通用机制)。
|
||||
|
||||
企业微信若改为走驱动:可重构 `EntWechatChannel` 调 `Bot::sendMarkdown('ent_wechat', ...)`,与 `driver/bot/EntWechat` 对齐,属于核心改造。
|
||||
|
||||
## 15. 本地验证
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
foreach (["dingtalk","ding_talk"] as $t) {
|
||||
try { echo $t, " => ", (new \crmeb\services\bot\Bot($t))->sendText("test"), " (class ok)\n"; }
|
||||
catch (\Throwable $e) { echo $t, " => ", $e->getMessage(), "\n"; }
|
||||
}'
|
||||
```
|
||||
|
||||
预期:`ding_talk` 可实例化;`dingtalk` 可能报 not supported(说明调用名必须用下划线形式)。
|
||||
|
||||
## 16. capabilities 说明
|
||||
|
||||
`send_text` / `send_markdown` / `send_card` 声明供后台展示;`DingTalkChannel` 仅使用 markdown。卡片消息需业务侧额外调用 `Bot::sendCard`,当前通知模板未默认支持。
|
||||
|
||||
## 17. 与 `plugin.json` key 不一致案例
|
||||
|
||||
`driver/bot/DingTalk/` 的 `key` 为 `dingtalk`,目录为 `DingTalk`。复合标识安装时用 `driver:bot:dingtalk`。切勿把目录改成小写 `dingtalk/`,Linux PSR-4 会加载失败。
|
||||
@ -0,0 +1,280 @@
|
||||
# 驱动分册 · 商品采集(copyproduct)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写从电商平台 URL 采集商品专属内容。
|
||||
|
||||
## 1. 选型判定
|
||||
|
||||
| 需求 | 落本类型 |
|
||||
| --- | --- |
|
||||
| 粘贴淘宝/京东/拼多多等链接,拉取标题/图/规格/详情进商品编辑页 | `backend/driver/copyproduct/{Dir}/` |
|
||||
| 自己写爬虫服务、改商品保存规则 | 驱动只负责 `copy()` 返回结构;保存逻辑在 `CopyProductServices` |
|
||||
| 一号通「平台服务」账号 | 内置 `yihaotong` 驱动,配置走 `sys_config` |
|
||||
|
||||
copyproduct 是少数**可零侵入接入**的类型:`CopyProductServices::invokeCopyDriver()` 经 `DriverRegistry::getDriverClass()` 动态实例化,无 `switch` 白名单。
|
||||
|
||||
## 2. 开工前必须告诉用户
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 一号通特殊配置 | `driver_key === 'yihaotong'` 时配置用 `yihaotong_config()`,不读驱动表 `value` |
|
||||
| 启用策略 | 同类型互斥(与 logistics 类似);无启用时业务**回退** `yihaotong` |
|
||||
| 平台列表 | 后台展示可读 `plugin.json` 的 `platforms`;`Copy99api` 等也在 `getPlatforms()` 返回 |
|
||||
| 管理端入口 | `CopyProductController` 部分接口仍直接调 `Serve`(一号通账户信息),与采集驱动并行 |
|
||||
|
||||
若新驱动要替换一号通成为默认,只需在后台启用并关闭其他 copyproduct 驱动;不必改 `invokeCopyDriver`(除非新 key 也要走全局账号)。
|
||||
|
||||
## 3. 文件清单
|
||||
|
||||
```text
|
||||
backend/driver/copyproduct/{Dir}/
|
||||
├── plugin.json
|
||||
└── Driver.php # extends crmeb\services\copyproduct\CopyProduct
|
||||
```
|
||||
|
||||
| 组件 | 路径 |
|
||||
| --- | --- |
|
||||
| 抽象基类 | `crmeb\services\copyproduct\CopyProduct` |
|
||||
| 业务入口 | `app/services/product/product/CopyProductServices.php` |
|
||||
|
||||
现役:
|
||||
|
||||
| 目录 | driver_key | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Copy99api/` | `copy99api` | 多平台 API Key |
|
||||
| `Yihaotong/` | `yihaotong` | 官方一号通,需开通采集 |
|
||||
|
||||
**推荐照抄**:`Copy99api/`(纯 `config_fields`、返回结构与业务对齐完整)。
|
||||
|
||||
## 4. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_copy",
|
||||
"name": "某某商品采集",
|
||||
"description": "对接某某采集 API",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/copyproduct/YourCopy",
|
||||
"default_install": false,
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某商品采集",
|
||||
"driver_key": "your_copy",
|
||||
"sort": 90,
|
||||
"description": "支持淘宝、京东链接采集",
|
||||
"capabilities": {
|
||||
"open": false,
|
||||
"multi_platform": true,
|
||||
"cache": true
|
||||
},
|
||||
"platforms": [
|
||||
{"name": "淘宝", "key": "taobao", "domain": "taobao.com", "icon": "icon-taobao"},
|
||||
{"name": "京东", "key": "jd", "domain": "jd.com", "icon": "icon-jd"}
|
||||
],
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_copy_apikey",
|
||||
"label": "API Key",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"group": "密钥配置",
|
||||
"default": ""
|
||||
}
|
||||
],
|
||||
"help_doc": {
|
||||
"title": "使用说明",
|
||||
"steps": ["注册采集平台", "填写 API Key", "启用本驱动"],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
copyproduct **专属** manifest 字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `platforms` | 后台展示支持平台;`domain` 用于链接识别提示 |
|
||||
| `capabilities.open` | 是否需要「开通服务」(一号通 true) |
|
||||
| `capabilities.multi_platform` | 是否多平台 |
|
||||
| `capabilities.cache` | 是否使用缓存(驱动内可选实现) |
|
||||
|
||||
## 5. Driver.php 方法契约
|
||||
|
||||
基类要求三个方法:
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\copyproduct\YourCopy;
|
||||
|
||||
use crmeb\services\copyproduct\CopyProduct;
|
||||
|
||||
class Driver extends CopyProduct
|
||||
{
|
||||
public function copy($url, array $options = [])
|
||||
{
|
||||
// 解析链接 → 请求 API → 归一化
|
||||
return ['status' => true, 'data' => $productInfo];
|
||||
// 或 ['status' => false, 'msg' => '原因'];
|
||||
}
|
||||
|
||||
public function getPlatforms(): array
|
||||
{
|
||||
return [/* 与 plugin.json platforms 对齐 */];
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return ['open' => false, 'multi_platform' => true, 'cache' => true];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.1 构造与配置
|
||||
|
||||
```php
|
||||
// CopyProductServices::invokeCopyDriver
|
||||
$config = $driverKey === 'yihaotong' ? yihaotong_config() : ($driver['config_fields'] ?? []);
|
||||
app()->make($class, [$config])->copy($url);
|
||||
```
|
||||
|
||||
非 yihaotong 时 `$config` 是 **带 `field`/`value` 的列表**或关联数组,驱动内需兼容(参考 `Copy99api::getApikey()`)。
|
||||
|
||||
### 5.2 `copy()` 成功时 `$data` 关键字段
|
||||
|
||||
业务会使用/补全的字段(须尽量提供):
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `store_name` | 商品名 |
|
||||
| `store_info` | 简介 |
|
||||
| `image` | 主图 URL |
|
||||
| `slider_image` | 轮播图 URL 数组 |
|
||||
| `price` / `ot_price` | 售价/原价 |
|
||||
| `description` | 详情 HTML |
|
||||
| `items` | 规格维度 `[['value'=>'颜色','detail'=>[...], 'add_pic'=>0\|1], ...]` |
|
||||
| `attrs` / `info.value` | SKU 组合(一号通/99API 结构略有差异,业务会读 `info.value`) |
|
||||
|
||||
失败统一:
|
||||
|
||||
```php
|
||||
['status' => false, 'msg' => '可读错误说明']
|
||||
```
|
||||
|
||||
### 5.3 链接识别
|
||||
|
||||
`CopyProductServices` 自带 `$host` 关键字列表;驱动内通常自行 `parseLink()`(见 `Copy99api`)。不支持的平台返回明确 `msg`。
|
||||
|
||||
### 5.4 可选 `open()`
|
||||
|
||||
一号通驱动实现开通接口;`CopyProductController` 开通流程走 `Serve`,不仅限驱动类。
|
||||
|
||||
## 6. 配置存储
|
||||
|
||||
| 存储 | 内容 |
|
||||
| --- | --- |
|
||||
| `eb_driver`(copyproduct) | 第三方 API Key 等 |
|
||||
| `sys_config` | 一号通 `yihaotong_appid` / `secret` |
|
||||
| 缓存 | 驱动内可用 `CacheService`(按 capabilities.cache) |
|
||||
|
||||
## 7. 调用链
|
||||
|
||||
```text
|
||||
管理端「商品采集」
|
||||
→ CopyProductController → CopyProductServices::copyProduct($type, $id, $shopid, $url)
|
||||
→ CopyDriverServices::getEnabledDrivers()[0]
|
||||
→ driver_key 空则 yihaotong
|
||||
→ invokeCopyDriver → DriverRegistry → Driver::copy($url)
|
||||
→ 成功则组装 productInfo(分类树、tempList、items 规格图转换等)
|
||||
→ 返回前端商品编辑草稿
|
||||
```
|
||||
|
||||
采集成功后图片仍可能走 `uploadDescriptionImage()` 拉远程图到附件库(依赖上传驱动)。
|
||||
|
||||
## 8. 逐步开发流程
|
||||
|
||||
1. 定 `driver_key`(蛇形)与 `{Dir}`(Studly)。
|
||||
2. 复制 `Copy99api`,改 API 端点与 `parseLink()`。
|
||||
3. 编写 `plugin.json`:`platforms` + `capabilities` + `config_fields`。
|
||||
4. 保证 `getPlatforms()` / `getCapabilities()` 与 manifest 一致。
|
||||
5. 静态检查;安装;后台**启用**并关闭其他采集驱动(若需切换)。
|
||||
6. 管理端粘贴各平台测试链接;核对多规格、`items[].detail` 带图场景。
|
||||
7. 保存商品前检查价格、库存默认值是否符合预期。
|
||||
|
||||
## 9. 参考实现
|
||||
|
||||
- `driver/copyproduct/Copy99api/Driver.php`:平台 API 表、缓存、SKU 归一化。
|
||||
- `driver/copyproduct/Yihaotong/Driver.php`:一号通 HTTP、开通逻辑。
|
||||
- `CopyProductServices` 第 124–153 行:规格 `detail` 字符串与 `{value,pic}` 兼容。
|
||||
|
||||
## 10. 打包(`.plugin-packages/`)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/copyproduct/YourCopy/Driver.php
|
||||
php think plugin:manifest-check
|
||||
zip -r ../.plugin-packages/driver-copyproduct-your_copy-1.0.0.zip driver/copyproduct/YourCopy/
|
||||
```
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
- [ ] 四名字对齐;实现基类三个方法
|
||||
- [ ] `copy()` 失败必带 `msg`;成功 `status===true` 且 `data.store_name` 非空
|
||||
- [ ] 无 `use app\`;密钥不出现在日志
|
||||
- [ ] 启用后 `getEnabledDrivers()` 首条为新驱动
|
||||
- [ ] 淘宝/京东/拼多多等声明平台各测一条链接
|
||||
- [ ] 多规格商品 `items`/`attrs` 在编辑页展示正常
|
||||
- [ ] 与一号通切换互斥无「静默回退 yihaotong」(除非未启用)
|
||||
|
||||
## 12. 常见失败现象
|
||||
|
||||
| 现象 | 原因 |
|
||||
| --- | --- |
|
||||
| 总是走一号通 | 无启用驱动;或启用的是 yihaotong |
|
||||
| `采集驱动不可用` | 类不存在;插件停用;driver_key 与目录不符 |
|
||||
| `请先配置99API Key` | `config_fields` 未保存 value;驱动未读 `field` 列表 |
|
||||
| `暂不支持该平台` | URL 域名不在驱动 hosts |
|
||||
| 规格空白 | 返回 `items`/`info.value` 结构与业务预期不符 |
|
||||
| 详情图仍外链 | 未点「下载详情图」;上传驱动未配置 |
|
||||
| 开通按钮无效 | 一号通账户未登录;与驱动 `open` 能力无关 |
|
||||
|
||||
## 13. 与一号通其他服务
|
||||
|
||||
同一 `yihaotong_appid`/`secret` 可用于短信、物流、面单、发票;采集驱动 `Yihaotong` 仅消费采集 API。第三方采集驱动与之独立,互不影响启用状态。
|
||||
|
||||
## 14. 管理端 API 与驱动边界
|
||||
|
||||
| 入口 | 行为 |
|
||||
| --- | --- |
|
||||
| `CopyProductServices::copyProduct` | 走 `invokeCopyDriver`,**动态驱动** |
|
||||
| `CopyProductController` 账户/开通 | 常调 `Serve` + `yihaotong_config()`,与是否启用 99API 无关 |
|
||||
| 复制成功后保存商品 | `CenterProductServices` 等,非驱动职责 |
|
||||
|
||||
二开采集驱动一般无需改 Controller;仅当新平台需额外 OAuth 参数时才考虑扩展接口(改 app)。
|
||||
|
||||
## 15. 缓存与限流建议
|
||||
|
||||
`Copy99api` 在驱动内对详情结果做缓存 key;第三方限流时返回明确 `msg`,避免 `CopyProductServices` 抛空 `AdminException`。capabilities.cache 为 true 时建议在驱动内使用 `CacheService`,key 含 url hash 与 driver_key。
|
||||
|
||||
## 16. 本地验证
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
echo crmeb\services\driver\DriverRegistry::getDriverClass("copyproduct", "copy99api"), PHP_EOL;
|
||||
foreach (crmeb\services\driver\DriverRegistry::scan("copyproduct") as $k => $d) {
|
||||
echo $k, " status=", $d["status"] ?? "-", PHP_EOL;
|
||||
}'
|
||||
```
|
||||
|
||||
有 API Key 时可在 tinker 式脚本中 `app()->make($class, [$configFields])->copy("https://...")` 看原始返回,再对照第 5.2 节字段表。
|
||||
|
||||
## 17. 规格图 `add_pic`
|
||||
|
||||
业务层根据 `items[].detail` 是否含 `pic` 自动置 `add_pic=1`;驱动若返回带图规格,应使用 `{value, pic}` 对象数组,与 99API 一致。纯字符串 detail 不会开启规格图上传。
|
||||
|
||||
## 18. 互斥与 default_install
|
||||
|
||||
内置 `yihaotong` 常 `default_install: true` 且 `is_builtin: true`;第三方驱动安装后默认关闭,管理员启用时会关闭其他 copyproduct 驱动。测试 99API 时确认后台仅启用一条,避免误读「仍走一号通」。
|
||||
@ -0,0 +1,309 @@
|
||||
# 驱动分册 · 邮件(email)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写邮件专属内容。
|
||||
|
||||
## 1. 何时用 / 何时不用
|
||||
|
||||
**适合做成 email 驱动:**
|
||||
|
||||
- 订单通知、验证码、营销邮件等:业务只投递「收件人 + 主题 + HTML 正文」,换 SMTP 或云邮件 API 即可。
|
||||
- 调用方已固定:`EmailChannel`(通知管线),以及 `EmailChannel::sendEmail()`(AI 助手、MCP 邮件工具等直调)。
|
||||
|
||||
**不要做成 email 驱动:**
|
||||
|
||||
- 要改通知标题/正文的变量渲染规则、收件人解析策略 → `EmailChannel::send()` / `renderPlaceholders()` 在 `app/`,不是驱动。
|
||||
- 需要邮件模板可视化编辑、发送队列独立后台 → 应做模块或走现有通知配置,不是新驱动类型。
|
||||
- 同一站点要**同时**用两个邮件服务商发不同业务 → 当前 **全局互斥单驱动**,需先问用户是否接受。
|
||||
|
||||
## 2. 开工前必须问用户的核心写死点
|
||||
|
||||
| 写死点 | 为何要问 | 代码依据 |
|
||||
| --- | --- | --- |
|
||||
| 协议型 SMTP 还是 HTTP API | SMTP 照抄 `Smtp`;REST 照抄 `AliyunDm` | `driver/email/Smtp`、`driver/email/AliyunDm` |
|
||||
| 发件地址从哪来 | SMTP 用 `smtp_username` 作 MAIL FROM;阿里云用 `account_name` | 各驱动 `plugin.json` + `Driver.php` |
|
||||
| 正文格式 | 通知层按 **HTML** 投递;纯文本需驱动内自行 strip 或 multipart | `EmailChannel::dispatchEmail()` |
|
||||
| `$options` 要不要用 | 通知把 `$message->variables` 整包透传;**无稳定键契约**,不要依赖 | `EmailChannel::send()` |
|
||||
| 是否允许自签证书 SMTP | 现役 SMTP 驱动关闭 SSL 校验;生产环境需和用户确认安全策略 | `driver/email/Smtp/Driver.php` |
|
||||
|
||||
邮件没有 pay/logistics 那种「必须改核心才能出现入口」的问题;`driver_key` 动态实例化,纯插件可接入。
|
||||
|
||||
## 3. 运行时链路(代码定位)
|
||||
|
||||
```text
|
||||
通知 / sendEmail()
|
||||
→ EmailChannel::dispatchEmail()
|
||||
→ EmailDriverServices::getEnabledDriver()
|
||||
→ config_fields → field => value
|
||||
→ app()->make(Email::class, [$driverKey, $config])
|
||||
→ crmeb\services\email\Email::resolveClass()
|
||||
→ driver\email\{Dir}\Driver::send($to, $subject, $content, $options)
|
||||
```
|
||||
|
||||
| 层级 | 路径 |
|
||||
| --- | --- |
|
||||
| 门面 | `backend/crmeb/services/email/Email.php` |
|
||||
| 接口 | `backend/crmeb/services/email/EmailInterface.php` |
|
||||
| 基类 | `backend/crmeb/services/email/BaseEmail.php`(抽象 `send`) |
|
||||
| 渠道 | `backend/app/services/message/channel/EmailChannel.php` |
|
||||
| 管理 | `backend/app/services/driver/email/EmailDriverServices.php` |
|
||||
| 后台 API | `backend/app/adminapi/route/driver.php` → `email_driver/*` |
|
||||
|
||||
`Email::invokeClass()` 构造驱动:
|
||||
|
||||
```php
|
||||
Container::getInstance()->invokeClass($class, [$this->name, $this->config, $this->configFile]);
|
||||
```
|
||||
|
||||
与 `BaseStorage` 一致:`__construct(string $name, array $config = [], ?string $configFile = null)`,**不要覆写构造函数**。
|
||||
|
||||
## 4. 目录与命名
|
||||
|
||||
```text
|
||||
backend/driver/email/{Dir}/
|
||||
├── plugin.json
|
||||
└── Driver.php # extends BaseEmail implements EmailInterface
|
||||
```
|
||||
|
||||
四个名字对齐规则见总览 3.2。示例:`aliyun_dm` → 目录 `AliyunDm`;`smtp` → `Smtp`。
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; echo \think\helper\Str::studly("aliyun_dm"), PHP_EOL;'
|
||||
# AliyunDm
|
||||
```
|
||||
|
||||
## 5. plugin.json 完整示例与字段表
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_mail",
|
||||
"name": "某某邮件",
|
||||
"description": "某某邮件推送驱动",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/email/YourMail",
|
||||
"default_install": false,
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某邮件",
|
||||
"driver_key": "your_mail",
|
||||
"class_name": "Driver",
|
||||
"icon": "icon-email",
|
||||
"sort": 50,
|
||||
"capabilities": ["send"],
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_mail_api_key",
|
||||
"label": "API Key",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"group": "账号"
|
||||
},
|
||||
{
|
||||
"field": "your_mail_from",
|
||||
"label": "发件地址",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "账号",
|
||||
"tip": "需在服务商控制台验证过的发信地址"
|
||||
},
|
||||
{
|
||||
"field": "your_mail_from_name",
|
||||
"label": "发件人名称",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"group": "账号",
|
||||
"default": "CRMEB"
|
||||
}
|
||||
],
|
||||
"help_doc": {
|
||||
"title": "某某邮件接入说明",
|
||||
"steps": [
|
||||
"在控制台创建发信域名/地址并完成验证",
|
||||
"创建 API 密钥",
|
||||
"填入上方配置并启用驱动",
|
||||
"在通知设置中开启邮件渠道并配置标题与 HTML 内容"
|
||||
],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**顶层字段:** 与总览 3.3 相同(`key`、`name`、`type`、`version`、`path` 必填)。
|
||||
|
||||
**`driver` 子对象(落库 `eb_driver`,`driver_type = email`):**
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `driver_key` | 运行时类型名,唯一 |
|
||||
| `capabilities` | 至少 `["send"]` |
|
||||
| `config_fields` | 后台表单;保存后写入每项 `value` |
|
||||
| `help_doc` | 可选,驱动配置页展示 |
|
||||
|
||||
邮件**没有** upload 专属的 `domain_template`、`bucket_fields`。
|
||||
|
||||
## 6. Driver.php 契约
|
||||
|
||||
### 6.1 必须实现(与接口完全一致)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\email\YourMail;
|
||||
|
||||
use crmeb\services\email\BaseEmail;
|
||||
use crmeb\services\email\EmailInterface;
|
||||
|
||||
class Driver extends BaseEmail implements EmailInterface
|
||||
{
|
||||
protected function initialize(array $config)
|
||||
{
|
||||
// $config 键 = config_fields 的 field
|
||||
}
|
||||
|
||||
public function send(string $to, string $subject, string $content, array $options = []): bool
|
||||
{
|
||||
if ($to === '') {
|
||||
return $this->setError('收件人不能为空');
|
||||
}
|
||||
if ($subject === '') {
|
||||
return $this->setError('邮件主题不能为空');
|
||||
}
|
||||
// $content 为 HTML;调 SMTP / HTTP API
|
||||
// 失败:return $this->setError('原因');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 返回类型必须是 **`bool`**;`EmailInterface` 与 `BaseEmail` 均声明 `: bool`。
|
||||
- 失败 **`return $this->setError('...')`**(返回值为 `false`),上层读 `getError()` 抛 `NoticeSendException`。
|
||||
- `$options` 可忽略,除非用户明确要求支持某扩展(需在交付说明写清键名)。
|
||||
|
||||
### 6.2 无其他必选方法
|
||||
|
||||
Unlike 支付/上传,邮件没有第二个业务入口方法;不要复制短信一号通的 `open()` / `temps()` 等空方法。
|
||||
|
||||
## 7. 配置存在哪张表
|
||||
|
||||
| 存储 | 说明 |
|
||||
| --- | --- |
|
||||
| `eb_driver`(`driver_type = email`) | 安装插件写入;`EmailDriver` 模型 scope 过滤 |
|
||||
| `eb_driver.config_fields` | 管理员在「邮件驱动」页保存的密钥、发件信息 |
|
||||
| 通知模板 | 邮件渠道用 `template_title` + `template_content`(HTML);就绪检查见 `NoticeReadiness` |
|
||||
|
||||
**互斥:** `EmailDriverServices::updateStatus()` 启用时 `disableAllExcept($id)`,全局仅一个启用邮件驱动。
|
||||
|
||||
**与短信差异:** 邮件 `syncDrivers()` 对新扫描到的内置驱动可能直接 `status=1`(`is_builtin`);第三方插件安装默认 `status=0`,需手动启用。
|
||||
|
||||
## 8. 通知侧行为(驱动作者需知)
|
||||
|
||||
`EmailChannel::send(NoticeMessage $message)` 在调用驱动前会:
|
||||
|
||||
1. 解析收件人:变量 `user_email` / `email`,或 `uid>0` 时查用户表 `email`。
|
||||
2. 校验 `title`、`content` 非空。
|
||||
3. 对标题与正文做 `{变量}` 替换(`renderPlaceholders`)。
|
||||
4. 调用 `send($to, $subject, $content, $message->variables)`。
|
||||
|
||||
平台通知(`type=2`)就绪检查要求业务变量含 `{user_email}` 或 `{email}`(见 `NoticeReadiness::reasons()`)。
|
||||
|
||||
驱动**不负责**拼主题/正文,只负责投递已渲染好的 HTML。
|
||||
|
||||
## 9. 从安装到冒烟(逐步)
|
||||
|
||||
### 9.1 静态检查
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/email/YourMail/Driver.php
|
||||
php think plugin:manifest-check
|
||||
```
|
||||
|
||||
### 9.2 安装、配置、启用
|
||||
|
||||
```bash
|
||||
# 安装 + 启用(管理员 token,接口基址按本地环境)
|
||||
curl -X POST 'http://192.168.1.169/adminapi/plugin/install' \
|
||||
-H 'Authori-zation: Bearer <token>' -d 'plugin_key=driver:email:your_mail'
|
||||
curl -X POST 'http://192.168.1.169/adminapi/plugin/enable' \
|
||||
-H 'Authori-zation: Bearer <token>' -d 'plugin_key=driver:email:your_mail'
|
||||
```
|
||||
|
||||
后台:**设置 → 邮件驱动** → 保存配置 → 启用(关闭其他邮件驱动)。
|
||||
|
||||
通知:**消息管理 → 通知设置** → 对应通知 → 开启邮件渠道 → 填写邮件标题与 HTML 内容。
|
||||
|
||||
### 9.3 Registry
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
foreach (crmeb\services\driver\DriverRegistry::scan("email") as $k => $d)
|
||||
echo $k, " => ", crmeb\services\driver\DriverRegistry::getDriverClass("email", $k), PHP_EOL;'
|
||||
```
|
||||
|
||||
### 9.4 冒烟
|
||||
|
||||
| 场景 | 期望 |
|
||||
| --- | --- |
|
||||
| `EmailChannel::sendEmail('you@example.com', '测试', '<p>hi</p>')` | 返回 `true`,收件箱收到 HTML |
|
||||
| 触发一条已配邮件渠道的用户通知 | 主题/变量替换正确 |
|
||||
| 错误 SMTP 密码或 API Key | `false` / 接口报错,含 `getError()` 文案 |
|
||||
|
||||
管理后台联调:`http://localhost:1619/admin`(账号见工作区 local-dev 规则)。
|
||||
|
||||
## 10. 参考实现对照表
|
||||
|
||||
| 目录 | driver_key | 场景 |
|
||||
| --- | --- | --- |
|
||||
| `Smtp/` | `smtp` | 企业邮箱 / 腾讯企业邮等 SMTP;465 SSL / 587 TLS;内置重试 |
|
||||
| `AliyunDm/` | `aliyun_dm` | 阿里云邮件推送 HTTP API |
|
||||
| `Qq/` | `qq` | QQ 邮箱 SMTP 变体(字段与 Smtp 类似) |
|
||||
|
||||
**推荐:** API 型抄 `AliyunDm/Driver.php`;SMTP 抄 `Smtp/Driver.php` 的连接与 AUTH 流程。
|
||||
|
||||
## 11. 打包交付
|
||||
|
||||
```bash
|
||||
cd backend/driver/email/YourMail
|
||||
zip -r "../../../../.plugin-packages/某某邮件-your_mail-1.0.0.zip" . \
|
||||
-x "*.DS_Store" -x "__MACOSX/*"
|
||||
```
|
||||
|
||||
zip 解压后根目录应直接看到 `plugin.json`。勿包含 `.env`、真实密码。
|
||||
|
||||
## 12. 验收清单
|
||||
|
||||
**静态(必须全部通过):**
|
||||
|
||||
- [ ] `php -l`、`php think plugin:manifest-check` 通过
|
||||
- [ ] 目录名 = `Str::studly(driver_key)`;`path` 与磁盘一致
|
||||
- [ ] `implements EmailInterface`,`send(...): bool` 签名一致
|
||||
- [ ] 配置不全、收件人为空、网关失败均 `return $this->setError(...)`,不抛未捕获异常
|
||||
- [ ] 密钥字段 `type: password`;代码与 JSON 无真实凭据
|
||||
- [ ] 无 `use app\` / `use modules\`;未改核心目录
|
||||
|
||||
**开发库冒烟:**
|
||||
|
||||
- [ ] 插件安装启用后 `DriverRegistry::scan('email')` 含你的 key
|
||||
- [ ] 启用驱动后 `getEnabledDriver()` 正确
|
||||
- [ ] 至少一封 HTML 邮件送达;失败场景错误信息可读
|
||||
- [ ] 日志不打印完整 API Key / SMTP 密码
|
||||
|
||||
## 13. 常见失败现象对照
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| `Driver [xxx] not supported.` | 目录与 studly 不一致 | 改目录名或 `driver_key` |
|
||||
| `未配置启用的邮件驱动` | 未启用或插件未 active | 启用驱动 + 插件 |
|
||||
| `邮件主题或内容为空` | 通知模板未配标题/正文 | 通知设置补全(非驱动问题) |
|
||||
| `邮件发送缺少收件邮箱` | 用户无 email 且变量未传 | 业务变量或用户资料 |
|
||||
| `缺少启用的邮件驱动`(就绪) | 与上类似,模板保存前检查 | 先启用驱动 |
|
||||
| SMTP `Failed to enable crypto` | 网络/端口/证书 | Smtp 驱动已 3 次重试;查防火墙与端口 |
|
||||
| 配置保存仍发送失败 | `field` 与 `initialize` 键不一致 | 对照 JSON 与 PHP |
|
||||
| 后台看不到新驱动 | 未安装插件 | 非「只拷文件夹」;走 plugin install |
|
||||
|
||||
---
|
||||
|
||||
**一句话:** 邮件驱动契约只有 **`send(): bool`**;互斥启用 + **`config_fields` 键名对齐** + 通知层已渲染 **HTML**,三块对了就能通。
|
||||
@ -0,0 +1,270 @@
|
||||
# 驱动分册 · 电子面单(express)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写电子面单专属内容。
|
||||
|
||||
## 1. 选型判定
|
||||
|
||||
| 需求 | 落本类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 发货时向快递商下单、取运单号/面单图 | `backend/driver/express/{Dir}/` + **同名** `backend/driver/logistics/{Dir}/` | 运行时类只从 `logistics` 命名空间加载 |
|
||||
| 仅查物流轨迹 | [logistics 分册](01-驱动-logistics-物流查询.md) | 不必注册 `express` 类型 |
|
||||
| 改发货流程、订单字段 | `app/` 业务 | 超出纯驱动范围,先问用户 |
|
||||
|
||||
电子面单是「插件元数据在 `express`,执行代码在 `logistics`」的双目录模型,这是当前架构的硬约束,不是可选优化。
|
||||
|
||||
## 2. 开工前必须告诉用户
|
||||
|
||||
| 约束 | 说明 |
|
||||
| --- | --- |
|
||||
| **双目录** | `crmeb\services\logistics\Express` 的 `$namespace = '\\driver\\logistics\\'`。`ExpressDriverServices::createExpressService($driverKey)` 只会实例化 `driver\logistics\{Studly}\Driver`,不会读 `driver\express\` 下的 PHP 类 |
|
||||
| **express 表** | `eb_driver` 中 `driver_type=express` 一行负责后台配置、启用、`ExpressDriverServices::scanDrivers()` |
|
||||
| **快递公司绑定** | `eb_express_company.driver_key` 可把某公司绑到你的驱动;未绑定时回退 `yihaotong` |
|
||||
| **全局开关** | `ExpressConfigServices` / 系统「电子面单」总开关关闭时,`OrderDeliveryServices` 直接拒绝电子面单发货 |
|
||||
|
||||
除双目录外,面单链路**没有**像 logistics 查询那样的 `driver_key` 白名单;新 `driver_key` 只要 logistics 侧类存在且 `eb_driver` 启用,即可被 `createExpressService()` 加载。但若 `dump()` 返回字段与现役不一致,仍可能要改 `OrderDeliveryServices` 读取 `$dump['kuaidinum']` 等逻辑——交付前与产品确认返回契约。
|
||||
|
||||
## 3. 文件清单
|
||||
|
||||
```text
|
||||
backend/driver/express/{Dir}/ # 安装、后台、plugin.json(driver_type=express)
|
||||
├── plugin.json
|
||||
└── Driver.php # 可与 logistics 同步;运行时默认不加载此类
|
||||
|
||||
backend/driver/logistics/{Dir}/ # 必须:Express 门面实际加载
|
||||
├── plugin.json # driver_type=logistics(若也要轨迹查询/一号通同款)
|
||||
└── Driver.php # namespace driver\logistics\{Dir}
|
||||
```
|
||||
|
||||
现役仅 **Yihaotong**(`driver_key=yihaotong`):
|
||||
|
||||
| 路径 | 作用 |
|
||||
| --- | --- |
|
||||
| `driver/express/Yihaotong/` | 后台电子面单驱动配置、默认安装 |
|
||||
| `driver/logistics/Yihaotong/` | `open/temp/express/query/dump` 等全部 API |
|
||||
|
||||
**推荐照抄**:以 `logistics/Yihaotong/Driver.php` 为真源,express 侧 `Driver.php` 与之保持一致或仅保留 express 目录的 `plugin.json` + 指向说明(现役两份代码并行维护)。
|
||||
|
||||
## 4. plugin.json(express 类型)
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_express",
|
||||
"name": "某某电子面单",
|
||||
"description": "对接某某面单 API",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/express/YourExpress",
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某电子面单",
|
||||
"driver_key": "your_express",
|
||||
"sort": 100,
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_express_api_key",
|
||||
"label": "API Key",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "账号"
|
||||
},
|
||||
{
|
||||
"field": "your_express_api_secret",
|
||||
"label": "API Secret",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"group": "账号"
|
||||
},
|
||||
{
|
||||
"field": "open",
|
||||
"label": "开启电子面单",
|
||||
"type": "switch",
|
||||
"group": "接口配置",
|
||||
"default": "1"
|
||||
}
|
||||
],
|
||||
"help_doc": {
|
||||
"title": "使用说明",
|
||||
"steps": ["在快递商开放平台开通面单", "填写密钥", "同步快递公司并绑定驱动", "配置寄件人/云打印机 siid"],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
logistics 侧若单独注册查询能力,另建 `driver/logistics/YourExpress/plugin.json`,`driver_key` 与 express **相同**,`capabilities` 可含 `query`(见 logistics 分册)。
|
||||
|
||||
一号通 express 清单含只读字段 `yihaotong_appid` / `yihaotong_appsecret`,值由 `ExpressDriverServices::getDetail()` 从 `sys_config` 注入,**不写入** driver 表。
|
||||
|
||||
## 5. Driver.php 核心方法
|
||||
|
||||
继承 `crmeb\services\logistics\BaseExpress`(**logistics 命名空间下的类**)。
|
||||
|
||||
| 方法 | 调用方 | 契约摘要 |
|
||||
| --- | --- | --- |
|
||||
| `open()` | 后台开通服务 | 返回服务商原始结构或 bool |
|
||||
| `temp(string $com)` | 面单模板选择 | 按快递公司编码返回模板列表 |
|
||||
| `express(int $type, int $page, int $limit)` | `ExpressServices::getPlatExpress()` | 同步 `eb_express` 快递公司字典 |
|
||||
| `query(...)` | 可选,与 logistics 查询共用 | 见 logistics 分册 |
|
||||
| `dump(array $data)` | **`OrderDeliveryServices` 电子面单发货** | 成功需含 **`kuaidinum`**(运单号) |
|
||||
|
||||
### 5.1 `dump($data)` 入参(业务组装)
|
||||
|
||||
`OrderDeliveryServices` 传入字段(节选):
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `com` | 快递公司编码 |
|
||||
| `to_name` / `to_tel` / `to_addr` | 收件人(订单用户) |
|
||||
| `from_name` / `from_tel` / `from_addr` | 寄件人(后台表单) |
|
||||
| `temp_id` | 面单模板 ID |
|
||||
| `siid` | 云打印机编号(公司级优先) |
|
||||
| `count` | 商品件数 |
|
||||
| `cargo` | 商品标题摘要 |
|
||||
| `order_id` | 订单号 |
|
||||
|
||||
一号通驱动还会读 `eb_express` 行上的 `partner_id`、`net` 等扩展字段(见 `logistics/Yihaotong/Driver.php` 的 `dump()`)。
|
||||
|
||||
### 5.2 `dump()` 返回值
|
||||
|
||||
业务代码:
|
||||
|
||||
```php
|
||||
$dump = $expressService->dump($expData);
|
||||
$orderInfo->delivery_id = $dump['kuaidinum'];
|
||||
```
|
||||
|
||||
新驱动必须保证成功时存在 `kuaidinum` 键;其他键按需扩展,但不要改已有键名除非同步改 `app/`。
|
||||
|
||||
## 6. 配置存储
|
||||
|
||||
| 表 / 配置 | 用途 |
|
||||
| --- | --- |
|
||||
| `eb_driver`(express) | 驱动元数据、`config_fields` + `value` |
|
||||
| `eb_express` | 平台快递公司;`syncExpress()` 从默认驱动的 `express()` 拉取 |
|
||||
| `eb_express_company` | 公司级 `driver_key`、`siid` |
|
||||
| `sys_config` | 一号通账号;全局 `config_export_siid` 等 |
|
||||
|
||||
`ExpressDriverServices::buildDriverConfig()` 把 `config_fields` 转为关联数组传入 `Express` 门面。
|
||||
|
||||
## 7. 调用链
|
||||
|
||||
```text
|
||||
管理端电子面单发货(express_record_type=2)
|
||||
→ OrderDeliveryServices::delivery(...)
|
||||
→ ExpressDriverServices::createExpressServiceByCode($delivery_code)
|
||||
→ getDriverKeyByCode() // eb_express_company 或 yihaotong
|
||||
→ app()->make(Express::class, [$driverKey, $config])
|
||||
→ driver\logistics\{Studly}\Driver::dump($expData)
|
||||
|
||||
同步快递公司
|
||||
→ ExpressServices::getPlatExpress()
|
||||
→ createExpressService() // 默认 yihaotong
|
||||
→ express(0, 0, 1000)
|
||||
|
||||
模板列表
|
||||
→ ExportController / ExpressTemplateController
|
||||
→ createExpressService(...) → temp($com)
|
||||
```
|
||||
|
||||
相关:`StoreIntegralOrderServices` 等模块发货同样注入 `ExpressDriverServices`。
|
||||
|
||||
## 8. 逐步开发流程
|
||||
|
||||
1. 确定 `driver_key`(如 `my_express`)与 `{Dir}=MyExpress`。
|
||||
2. 创建 **`driver/logistics/MyExpress/`**:实现 `Driver.php`(从 Yihaotong 复制改 API)。
|
||||
3. 创建 **`driver/express/MyExpress/`**:复制 `plugin.json`(`path` 指向 express 目录);`Driver.php` 建议与 logistics 保持一致,避免日后误用旧类。
|
||||
4. 若需物流查询且 driver_key 不在查询白名单,另走 logistics 分册的核心扩展流程。
|
||||
5. `php think plugin:manifest-check`;分别安装 `driver:express:my_express` 与(如有)`driver:logistics:my_express`。
|
||||
6. 后台启用 express 驱动;在快递公司管理绑定 `driver_key`;配置寄件人与模板。
|
||||
7. 测试订单电子面单发货,确认 `delivery_id` 写入运单号。
|
||||
|
||||
## 9. 参考实现
|
||||
|
||||
- `backend/driver/logistics/Yihaotong/Driver.php`:`dump()` 完整参数校验、读 `eb_express` 扩展、IMAGE/PRINT 模式。
|
||||
- `backend/driver/express/Yihaotong/plugin.json`:只读一号通字段、help_doc 步骤。
|
||||
|
||||
## 10. 打包(`.plugin-packages/`)
|
||||
|
||||
面单交付通常含**两个目录**(或一个 zip 内两层路径):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php think plugin:manifest-check
|
||||
|
||||
zip -r ../.plugin-packages/driver-express-my_express-1.0.0.zip \
|
||||
driver/express/MyExpress driver/logistics/MyExpress
|
||||
```
|
||||
|
||||
若 logistics 插件单独安装,另打 `driver-logistics-my_express-1.0.0.zip`。版本首版均为 `1.0.0`。
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
- [ ] `express` 与 `logistics` 目录名相同且均等于 `Str::studly(driver_key)`
|
||||
- [ ] 两个 `plugin.json` 的 `driver_key` 一致;`path` 各自匹配真实目录
|
||||
- [ ] `createExpressService('your_key')` 能加载类且无 `class not exists`
|
||||
- [ ] `dump()` 成功返回含 `kuaidinum`
|
||||
- [ ] 电子面单总开关开启;测试订单发货写入运单号
|
||||
- [ ] `getPlatExpress()` 能同步(若实现了 `express()`)
|
||||
- [ ] 未改 `app/`/`crmeb/` 或变更已在交付说明列出
|
||||
|
||||
## 12. 常见失败现象
|
||||
|
||||
| 现象 | 原因 |
|
||||
| --- | --- |
|
||||
| 后台有驱动,发货报 class not exists | 只有 `driver/express/`,缺少 `driver/logistics/{Dir}/Driver.php` |
|
||||
| 始终走一号通 | 快递公司未绑 `driver_key`;或新驱动未启用 |
|
||||
| `电子面单已关闭` | 全局 export 开关 off |
|
||||
| `快递公司编码缺失` / 模板缺失 | 表单字段未选 `delivery_code`、`express_temp_id` |
|
||||
| `寄件人信息缺失` | `dump()` 必填项与后台表单不一致 |
|
||||
| 运单号为空 | `dump()` 返回键名不是 `kuaidinum` |
|
||||
| 模板接口 401 | 密钥在 express 表未保存;或一号通未登录 |
|
||||
|
||||
## 13. 与 logistics 查询分册的边界
|
||||
|
||||
- 同一 `Yihaotong` 类既面单又查询:logistics 启用驱动影响**轨迹**;express 启用驱动影响**发货与 sync**。
|
||||
- 纯第三方面单、不用其查询 API:仍建议保留 logistics 副本仅实现 `dump/temp/express`,`query()` 可空实现。
|
||||
- 物流轨迹白名单仅影响 `ExpressServices::query()`,不阻止 `dump()`。
|
||||
|
||||
## 14. 本地验证命令(只读 / 开发库)
|
||||
|
||||
确认 logistics 侧类可被门面加载(将 `my_express` 换成你的 key):
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
$key = "yihaotong";
|
||||
echo crmeb\services\driver\DriverRegistry::getDriverClass("logistics", $key), PHP_EOL;
|
||||
$svc = app()->make(\crmeb\services\logistics\Express::class, [$key, []]);
|
||||
echo get_class($svc), PHP_EOL;'
|
||||
```
|
||||
|
||||
扫描 express 类型插件是否入库:
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
foreach (crmeb\services\driver\DriverRegistry::scan("express") as $k => $d) {
|
||||
echo $k, " status=", $d["status"] ?? "-", PHP_EOL;
|
||||
}'
|
||||
```
|
||||
|
||||
`ExpressDriverServices::syncDrivers()` 会在部分后台操作触发目录与 `eb_express_driver` 同步;插件安装路径仍应优先走「插件管理」安装,避免只拷目录未写 `owner_plugin_id`。
|
||||
|
||||
## 15. 与 `ExportController` / 模板管理
|
||||
|
||||
- 电子面单模板列表:依赖 `temp($com)` 返回;模板 ID 写入发货表单项 `express_temp_id`。
|
||||
- 云打印机 `siid`:`ExpressDriverServices::getCompanySiid()` 读 `eb_express_company.siid`,再回退 `ExpressConfigServices::getItem('siid')`。
|
||||
- 关闭电子面单:`ExpressConfigServices::getItem('open', 0) === 0` 时任何 `dump()` 不会被执行。
|
||||
|
||||
## 16. 双目录维护策略(推荐)
|
||||
|
||||
| 策略 | 做法 |
|
||||
| --- | --- |
|
||||
| 最小漂移 | 仅维护 `logistics/{Dir}/Driver.php`;express 目录只保留 `plugin.json`(现役两份代码相同,合并前保持同步) |
|
||||
| 交付说明 | zip 内必须同时包含两个 path,安装两次或同一包内两个 manifest(若拆两个插件则各有一个 `plugin.json`) |
|
||||
| 类命名空间 | logistics 内必须是 `namespace driver\logistics\{Dir}`;express 目录若留 `Driver.php`,命名空间为 `driver\express\{Dir}`,**不会被** `createExpressService` 加载 |
|
||||
|
||||
## 17. 扩展阅读
|
||||
|
||||
- 总览第 2 节:express 与 logistics 命名空间约束。
|
||||
- `backend/driver/express/DEVELOPMENT.md`:历史草稿,以本篇与代码为准。
|
||||
- 订单发货:`app/services/order/OrderDeliveryServices.php` 中 `express_record_type == 2` 分支。
|
||||
@ -0,0 +1,258 @@
|
||||
# 驱动分册 · 电子发票(invoice)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写发票专属内容。
|
||||
|
||||
## 1. 选型判定
|
||||
|
||||
| 需求 | 落本类型 |
|
||||
| --- | --- |
|
||||
| 对接第三方电子发票(开具、红冲、下载 PDF) | `backend/driver/invoice/{Dir}/` |
|
||||
| 只改订单发票申请 UI、税率配置 | `app/` + 系统配置 `elec_invoice_*` |
|
||||
|
||||
发票驱动负责与税控/服务商 HTTP 通信;订单发票记录仍在 `eb_store_order_invoice` 等业务表,由 `OrderInvoiceServices` 维护。
|
||||
|
||||
## 2. 开工前必须告诉用户
|
||||
|
||||
发票驱动可安装进 `driver/invoice/`,但**现役调用方写死一号通**:
|
||||
|
||||
| 位置 | 代码事实 |
|
||||
| --- | --- |
|
||||
| `app/services/order/OrderInvoiceServices.php` | `app()->make(Invoice::class, [yihaotong_config()])`,未读启用驱动 |
|
||||
| `app/api/controller/v1/order/StoreOrderInvoiceController.php` | 同上 |
|
||||
| `crmeb\services\invoice\Invoice::getDefaultDriver()` | 固定返回 `'yihaotong'` |
|
||||
| `Invoice::invokeClass()` | 始终 `new AccessTokenServeService($config['yihaotong_appid'], $config['yihaotong_appsecret'])` |
|
||||
|
||||
新驱动要实际上线,需用户同意至少:调用方改为按 `eb_driver` 启用项实例化 `Invoice`、或传入正确 `driver_key` 与配置;门面层若新服务商不用一号通 Token,还要改 `invokeClass()` 构造方式。
|
||||
|
||||
只交付驱动目录时,须在交付说明中列出上述核心待办。
|
||||
|
||||
## 3. 文件清单
|
||||
|
||||
```text
|
||||
backend/driver/invoice/{Dir}/
|
||||
├── plugin.json
|
||||
└── Driver.php # namespace driver\invoice\{Dir}; extends BaseInvoice
|
||||
```
|
||||
|
||||
| 组件 | 路径 |
|
||||
| --- | --- |
|
||||
| 基类 | `crmeb\services\invoice\BaseInvoice` |
|
||||
| 门面 | `crmeb\services\invoice\Invoice`(`BaseManager`,命名空间 `\driver\invoice\`) |
|
||||
| Token | 一号通用 `crmeb\services\yihaotong\AccessTokenServeService` |
|
||||
|
||||
现役:`backend/driver/invoice/Yihaotong/`(`driver_key=yihaotong`)。
|
||||
|
||||
## 4. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_invoice",
|
||||
"name": "某某电子发票",
|
||||
"description": "对接某某发票平台",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/invoice/YourInvoice",
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某电子发票",
|
||||
"driver_key": "your_invoice",
|
||||
"sort": 100,
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_invoice_app_id",
|
||||
"label": "AppID",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "账号"
|
||||
},
|
||||
{
|
||||
"field": "your_invoice_app_secret",
|
||||
"label": "AppSecret",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"group": "账号"
|
||||
},
|
||||
{
|
||||
"field": "invoice_switch",
|
||||
"label": "发票服务开关",
|
||||
"type": "switch",
|
||||
"group": "基础",
|
||||
"default": "1"
|
||||
}
|
||||
],
|
||||
"help_doc": {
|
||||
"title": "配置说明",
|
||||
"steps": ["开通电子发票资质", "填写密钥", "(若未改核心)仅作备用驱动"],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
第三方驱动不要写 `is_builtin: true`。一号通驱动字段 `yihaotong_appid`/`secret` 在后台只读,值来自 `sys_config`(与短信/面单一致)。
|
||||
|
||||
## 5. Driver.php 方法契约
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\invoice\YourInvoice;
|
||||
|
||||
use crmeb\services\invoice\BaseInvoice;
|
||||
|
||||
class Driver extends BaseInvoice
|
||||
{
|
||||
protected function initialize(array $config = []): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
// 非一号通:在此初始化自有 HTTP 客户端;勿 use app\
|
||||
}
|
||||
|
||||
public function invoiceIssuanceUrl(array $params = []) { /* iframe 开票页 */ }
|
||||
public function downloadInvoice(string $invoiceNum = '') { /* PDF */ }
|
||||
public function invoiceInfo(string $invoiceNum = '') { /* 详情 */ }
|
||||
public function category(array $params = []) { /* 税收分类 */ }
|
||||
public function invoiceIssuance(string $unique = '', array $params = []) { /* 开具 */ }
|
||||
public function applyRedInvoice(array $params = []) { /* 红字申请 */ }
|
||||
public function redInvoiceIssuance(array $params = []) { /* 红冲开具 */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.1 业务实际调用的方法
|
||||
|
||||
| 方法 | 调用场景 | 期望返回 |
|
||||
| --- | --- | --- |
|
||||
| `invoiceIssuance($unique, $params)` | 自动/手动开票,`$unique` 为中台订单号 `order_no` | `['status' => 200, 'data' => ['invoice_num', 'invoice_type', 'invoice_serial_number', ...]]` |
|
||||
| `redInvoiceIssuance($params)` | 退款冲红 | `['status' => 200, ...]`,参数含 **`invoiceNum`**(camelCase) |
|
||||
| `invoiceIssuanceUrl` / `downloadInvoice` / `invoiceInfo` | 用户端发票详情、下载 | 与一号通 API 形状对齐或同步改 Controller |
|
||||
|
||||
`OrderInvoiceServices::invoicing()` 组装 `$data` 含:`invoice_type`(81/82)、`tax_id`、`is_enterprise`、`goods[]`(`cate_id`、`store_name`、`unit_price`、`num`、`tax_rate`),税率/类目来自 `sys_config('elec_invoice_cate')`、`elec_invoice_tax_rate`。
|
||||
|
||||
### 5.2 一号通参考
|
||||
|
||||
`driver/invoice/Yihaotong/Driver.php` 使用常量路径如 `v2/invoice/invoice_issuance`;全部经 `$this->accessToken->httpRequest()`。
|
||||
|
||||
## 6. 配置存储
|
||||
|
||||
| 来源 | 内容 |
|
||||
| --- | --- |
|
||||
| `eb_driver`(invoice) | 驱动元数据、非一号通密钥 |
|
||||
| `sys_config` | `yihaotong_appid`/`secret`;`elec_invoice`、`auto_invoice`;`elec_invoice_cate`、`elec_invoice_tax_rate` |
|
||||
| `yihaotong_config()` | `app/common.php` _helper,合并一号通账号 |
|
||||
|
||||
发票驱动**不应**把第三方密钥写入 `system_config`(一号通平台账号除外)。
|
||||
|
||||
## 7. 调用链
|
||||
|
||||
```text
|
||||
自动开票定时/队列
|
||||
→ OrderInvoiceServices::autoInvoice / invoicing
|
||||
→ Invoice(yihaotong_config()) → yihaotong Driver
|
||||
|
||||
用户端发票
|
||||
→ StoreOrderInvoiceController
|
||||
→ Invoice(yihaotong_config()) → invoiceIssuanceUrl / downloadInvoice ...
|
||||
|
||||
退款冲红
|
||||
→ OrderInvoiceServices::redInvoiceIssuance
|
||||
→ redInvoiceIssuance(['invoiceNum' => ..., 'apply_type' => '01'])
|
||||
```
|
||||
|
||||
启用开关:`sys_config('elec_invoice') == 1` 且订单发票记录状态满足业务条件。
|
||||
|
||||
## 8. 逐步开发流程
|
||||
|
||||
1. 定 `driver_key` 与目录名 `{Dir}`。
|
||||
2. 复制 `Yihaotong/Driver.php`,替换 API 路径与签名。
|
||||
3. 若服务商不用一号通 Token:在 `initialize()` 内自建客户端;**同时**规划核心改造 `Invoice::invokeClass()`(需用户授权)。
|
||||
4. 编写 `plugin.json`,`config_fields` 与 `initialize()` 读取字段一致。
|
||||
5. 静态检查、后台安装驱动。
|
||||
6. **未改核心**:仅验证 `DriverRegistry::scan('invoice')` 与手动 `app()->make(Invoice::class, [$driverKey, ...])` 实验(可能因 Token 构造失败)。
|
||||
7. **已改核心**:切换启用驱动,走完整开票/红冲回归。
|
||||
|
||||
## 9. 参考实现
|
||||
|
||||
- `backend/driver/invoice/Yihaotong/`:完整 API 常量与方法。
|
||||
- `backend/driver/invoice/DEVELOPMENT.md`:方法列表(以代码为准)。
|
||||
|
||||
## 10. 打包(`.plugin-packages/`)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/invoice/YourInvoice/Driver.php
|
||||
php think plugin:manifest-check
|
||||
zip -r ../.plugin-packages/driver-invoice-your_invoice-1.0.0.zip driver/invoice/YourInvoice/
|
||||
```
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
- [ ] 目录、`key`、`driver_key`、`path` 四者对齐
|
||||
- [ ] 无 `use app\` / `use modules\`
|
||||
- [ ] `invoiceIssuance` / `redInvoiceIssuance` 返回含 `status` 字段,成功为 200
|
||||
- [ ] 红冲参数使用 `invoiceNum` 键名
|
||||
- [ ] 清单无真实密钥
|
||||
- [ ] **核心已改**:启用新驱动后自动开票写入 `invoice_num`
|
||||
- [ ] **核心未改**:行为仍为 yihaotong(符合预期说明)
|
||||
|
||||
## 12. 常见失败现象
|
||||
|
||||
| 现象 | 原因 |
|
||||
| --- | --- |
|
||||
| 安装了新驱动但从不开票 | 调用方仍 `yihaotong_config()` + 默认 driver |
|
||||
| `缺少一号通凭证` | 未配置平台账号;或非一号通驱动却被一号通 Token 构造 |
|
||||
| 开票失败无前台提示 | `invoicing()` catch 后只写 Log |
|
||||
| 红冲报发票状态有误 | 订单发票记录 `unique_num`/`is_invoice` 不满足 |
|
||||
| `Driver [x] not supported` | 目录名与 `Str::studly(driver_key)` 不一致 |
|
||||
| 税率/类目错误 | `elec_invoice_*` 系统配置未设 |
|
||||
|
||||
## 13. 与其他一号通能力
|
||||
|
||||
短信、面单、采集、物流查询均可共用 `yihaotong_appid`/`secret`;发票驱动 `initialize()` 与它们相同,从 `sys_config` 读取。独立发票服务商应使用 `eb_driver.config_fields`,并在核心改造后由 `buildDriverConfig` 类逻辑传入驱动。
|
||||
|
||||
## 14. 系统配置项(业务侧,非驱动表)
|
||||
|
||||
| sys_config 键 | 作用 |
|
||||
| --- | --- |
|
||||
| `elec_invoice` | 总开关,关则不走开票 |
|
||||
| `auto_invoice` | 是否自动开票 / 自动冲红任务 |
|
||||
| `elec_invoice_cate` | 默认税收分类编码(写入 goods.cate_id) |
|
||||
| `elec_invoice_tax_rate` | 默认税率 |
|
||||
|
||||
改这些键只影响组装参数,不替换驱动实现;插件开发者一般无需改 install SQL。
|
||||
|
||||
## 15. 本地验证
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
echo crmeb\services\driver\DriverRegistry::getDriverClass("invoice", "yihaotong"), PHP_EOL;'
|
||||
```
|
||||
|
||||
手动实例化(需一号通账号已配置):
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
$cfg = yihaotong_config();
|
||||
$inv = app()->make(\crmeb\services\invoice\Invoice::class, [$cfg]);
|
||||
echo get_class($inv), PHP_EOL;'
|
||||
```
|
||||
|
||||
未改核心前,即使 `DriverRegistry::scan('invoice')` 有多行,生产开票仍走默认 yihaotong。
|
||||
|
||||
## 16. 核心改造检查清单(用户同意后)
|
||||
|
||||
- [ ] `OrderInvoiceServices` / `StoreOrderInvoiceController` 读取启用 invoice 驱动
|
||||
- [ ] `Invoice::invokeClass()` 按驱动构造 Token/HTTP,而非写死一号通
|
||||
- [ ] `getDefaultDriver()` 与后台启用联动
|
||||
- [ ] 用户端下载/iframe 字段与新区块对齐
|
||||
- [ ] 红冲仍传 `invoiceNum` camelCase
|
||||
|
||||
## 17. 失败排障补充
|
||||
|
||||
| 日志/现象 | 排查 |
|
||||
| --- | --- |
|
||||
| `自动开具发票失败` + Log | 一号通 HTTP 401、goods 类目为空 |
|
||||
| `unique_num` 为空 | `invoiceIssuance` 未返回 200 或异常被 catch |
|
||||
| iframe 空白 | URL 字段与前端取值不一致 |
|
||||
| 插件启用但无效果 | 未改核心时的预期行为,交付说明须写明 |
|
||||
@ -0,0 +1,291 @@
|
||||
# 驱动分册 · 物流查询(logistics)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写物流轨迹查询专属内容。
|
||||
|
||||
## 1. 选型判定
|
||||
|
||||
| 需求 | 落本类型 | 不要落本类型 |
|
||||
| --- | --- | --- |
|
||||
| 根据运单号查轨迹、在订单/售后展示物流节点 | `backend/driver/logistics/{Dir}/` | 电子面单打印、同步快递公司列表 → [express 分册](01-驱动-express-电子面单.md) |
|
||||
| 只换查询 API,业务仍走 `ExpressServices::query()` | 驱动 | 改订单状态机、改缓存 key 规则 → 需改 `app/`,先问用户 |
|
||||
|
||||
物流查询与电子面单在业务上相关,但在仓库里是两个 `driver_type`:`logistics` 只管轨迹;面单走 `express` + 同名 `logistics` 实现类(见 express 分册)。
|
||||
|
||||
## 2. 开工前必须告诉用户
|
||||
|
||||
物流查询驱动可以零侵入安装到 `driver/logistics/`,但**运行时是否被调用**由 `app/services/shipping/ExpressServices.php` 写死:
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --- | --- |
|
||||
| `resolveEnabledQueryDriver()` | 启用驱动的 `driver_key` 必须在 `['aliyun_express','yihaotong']`,否则抛 `物流查询驱动不支持` |
|
||||
| `query()` 内 `switch ($driverKey)` | 只解析上述两种第三方返回结构;`default` 同样抛异常 |
|
||||
|
||||
因此:只交付驱动目录时,后台可以安装、启用,但**用户端/管理端查轨迹仍失败**,除非用户同意改 `ExpressServices` 两处白名单与解析分支。
|
||||
|
||||
同类型启用互斥:`logistics` 属于总览 3.4 中「`sms`/`email`/`copyproduct`/`logistics` 同类型仅一个启用」;新驱动 `is_builtin` 为真时,安装后默认关闭,需管理员手动切换。
|
||||
|
||||
## 3. 文件清单
|
||||
|
||||
```text
|
||||
backend/driver/logistics/{Dir}/
|
||||
├── plugin.json # driver_type 写入 eb_driver 时为 logistics
|
||||
├── Driver.php # namespace driver\logistics\{Dir}; extends BaseExpress
|
||||
└── extend/ # 可选:第三方 SDK
|
||||
```
|
||||
|
||||
| 基类 / 门面 | 路径 |
|
||||
| --- | --- |
|
||||
| `crmeb\services\logistics\BaseExpress` | 构造函数 `(string $name, array $config, string $configFile)` |
|
||||
| `crmeb\services\logistics\Express` | 命名空间固定 `\driver\logistics\`,`resolveClass()` 用 `Str::studly($driverKey)` |
|
||||
|
||||
现役参考:
|
||||
|
||||
| 目录 | driver_key | 特点 |
|
||||
| --- | --- | --- |
|
||||
| `AliyunExpress/` | `aliyun_express` | 阿里云市场 AppCode,返回 `result.list` + `deliverystatus` |
|
||||
| `Yihaotong/` | `yihaotong` | 一号通凭证来自 `sys_config`,返回 `content[]` + `ischeck` |
|
||||
|
||||
**推荐照抄**:`backend/driver/logistics/AliyunExpress/`(单方法 `query()`、结构稳定、无一号通依赖)。
|
||||
|
||||
## 4. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_logistics",
|
||||
"name": "某某物流查询",
|
||||
"description": "对接某某物流轨迹 API",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/logistics/YourLogistics",
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某物流查询",
|
||||
"driver_key": "your_logistics",
|
||||
"icon": "",
|
||||
"sort": 90,
|
||||
"capabilities": ["query"],
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_logistics_app_code",
|
||||
"label": "AppCode",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "基础配置",
|
||||
"tip": "在服务商控制台获取"
|
||||
}
|
||||
],
|
||||
"help_doc": {
|
||||
"title": "配置说明",
|
||||
"steps": ["开通 API", "填写 AppCode", "后台启用本驱动并关闭其他物流查询驱动"],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 第三方驱动**不要**写 `is_builtin: true`(需自备密钥);内置一号通/阿里云按产品策略处理。
|
||||
- `capabilities` 声明 `query` 即可;业务层当前不读该字段做分支,但后台展示会用到。
|
||||
- 字段名建议带 `driver_key` 前缀,避免与其他驱动混淆。
|
||||
|
||||
## 5. Driver.php 与方法契约
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\logistics\YourLogistics;
|
||||
|
||||
use crmeb\services\logistics\BaseExpress;
|
||||
use crmeb\utils\Http;
|
||||
|
||||
class Driver extends BaseExpress
|
||||
{
|
||||
public function query(string $no = '', string $type = '', string $appCode = '')
|
||||
{
|
||||
// 签名、HTTP、解析第三方 JSON
|
||||
// 失败:返回与 AliyunExpress 同形的空结构,或 setError + false(看参考驱动风格)
|
||||
}
|
||||
|
||||
public function open() {}
|
||||
public function dump($data) {}
|
||||
public function temp(string $com) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.1 `query()` 签名(现役约定)
|
||||
|
||||
| 参数 | 含义 |
|
||||
| --- | --- |
|
||||
| `$no` | 运单号 |
|
||||
| `$type` | 快递公司编码(`eb_express.code`);阿里云可传空由接口识别 |
|
||||
| `$appCode` | 仅阿里云分支:`ExpressServices` 从 `config_fields` 里读 `app_code` 传入;一号通分支改传 `$com` + 收件手机 |
|
||||
|
||||
一号通实现见 `driver/logistics/Yihaotong/Driver.php` 的 `query(string $num, ?string $com, $phone)`——与基类文档签名略有出入,门面按位置传参,新驱动应对齐**调用方**而非 DEVELOPMENT 旧表。
|
||||
|
||||
### 5.2 业务层期望的返回形状
|
||||
|
||||
`ExpressServices::query()` 最终给前端的是**统一节点数组**:
|
||||
|
||||
```php
|
||||
[
|
||||
['time' => '2026-01-01 10:00:00', 'status' => '节点描述'],
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
中间转换(必须在核心白名单内或用户同意扩展后实现):
|
||||
|
||||
| driver_key | 驱动原始返回 | 核心提取逻辑 |
|
||||
| --- | --- | --- |
|
||||
| `aliyun_express` | `['result' => ['list' => [...], 'deliverystatus' => n]]` | `list` 每项映射为 `time`/`status`;`deliverystatus >= 3` 时长缓存设为永久 |
|
||||
| `yihaotong` | `['content' => [...], 'ischeck' => 0\|1]` | `content` 映射;`ischeck == 1` 时长缓存永久 |
|
||||
|
||||
新驱动若接入核心,需在 `switch` 中增加 case,并把第三方字段映射为上述 `time`/`status`。
|
||||
|
||||
### 5.3 可选方法
|
||||
|
||||
| 方法 | 用途 |
|
||||
| --- | --- |
|
||||
| `open()` | 一号通「开通物流」;纯 AppCode 类 API 可空实现 |
|
||||
| `express()` | 拉取快递公司列表;`ExpressServices::getPlatExpress()` 通过**默认面单驱动**调用,不是 logistics 启用驱动 |
|
||||
| `dump()` / `temp()` | 面单能力;纯查询驱动留空 |
|
||||
|
||||
## 6. 配置存储
|
||||
|
||||
| 存储 | 内容 |
|
||||
| --- | --- |
|
||||
| `eb_driver` | `driver_type=logistics`,`config_fields` 含管理员填写的 `value` |
|
||||
| Redis 缓存 | `logistics:{driver_key}:{cacheName}`,轨迹;空结果另有 `{...}:empty:{md5}` 120 秒 |
|
||||
|
||||
禁止把 AppCode、密钥写入 `eb_system_config`(一号通账号除外,那是平台级 `yihaotong_appid` / `yihaotong_appsecret`)。
|
||||
|
||||
## 7. 调用链(app 层)
|
||||
|
||||
```text
|
||||
用户端/管理端查物流
|
||||
→ app/services/shipping/ExpressServices::query($cacheName, $expressNum, $com, $phone)
|
||||
→ resolveEnabledQueryDriver() // 白名单 + 插件启用校验 ModuleStateChecker
|
||||
→ app()->make(Express::class, [$driverKey, ...])
|
||||
→ driver\logistics\{Studly}\Driver::query(...)
|
||||
→ 写缓存 → 返回节点数组
|
||||
|
||||
订单详情首屏
|
||||
→ ExpressServices::getCachedTracks() // 只读缓存,不调第三方
|
||||
```
|
||||
|
||||
相关只读入口:`StoreOrderServices`、售后物流、`api` 订单物流接口(均间接依赖 `ExpressServices`)。
|
||||
|
||||
## 8. 逐步开发流程
|
||||
|
||||
1. **定 driver_key**(小写蛇形,如 `kuaidi100`),运行 `Str::studly` 得到目录名 `Kuaidi100`。
|
||||
2. **复制** `AliyunExpress` 目录为模板,改 `plugin.json` 的 `key`、`path`、`driver_key` 一致。
|
||||
3. **实现 `query()`**:HTTP 用 `crmeb\utils\Http` 或 `extend/` 内 SDK;失败信息用 `$this->setError()` 或返回空结构(与参考驱动一致)。
|
||||
4. **静态检查**(见第 10 节)。
|
||||
5. **后台安装并启用**;在「物流查询驱动」页关闭另一个驱动(互斥)。
|
||||
6. **若用户已授权改核心**:在 `ExpressServices` 增加 `case 'your_logistics':` 与字段映射;否则仅验证 `DriverRegistry::scan('logistics')` 与类加载。
|
||||
7. **联调**:真实运单 + 正确快递公司编码;核对缓存 key 切换驱动后不会串数据。
|
||||
|
||||
## 9. 参考实现要点
|
||||
|
||||
**AliyunExpress**(最小查询驱动):
|
||||
|
||||
- `query()` 始终返回数组,含 `result.list`,避免业务对 `false` 取下标。
|
||||
- AppCode 可从第三参或 `config_fields` 回退读取。
|
||||
|
||||
**Yihaotong**(平台账号型):
|
||||
|
||||
- `initialize()` 内用 `sys_config` / 构造参数组装 `AccessTokenServeService`。
|
||||
- 另含 `dump()`/`temp()`/`express()` 等面单能力;若只做查询,仍建议与 express 分册协同维护 logistics 副本。
|
||||
|
||||
## 10. 打包与交付(`.plugin-packages/`)
|
||||
|
||||
首版阶段 `version` 固定 `1.0.0`,不写 `upgrades/`(见 [架构规范「当前发布阶段」](../../../../rules/架构规范.md))。
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/logistics/YourLogistics/Driver.php
|
||||
php think plugin:manifest-check
|
||||
|
||||
mkdir -p ../.plugin-packages
|
||||
zip -r ../.plugin-packages/driver-logistics-your_logistics-1.0.0.zip driver/logistics/YourLogistics/
|
||||
```
|
||||
|
||||
交付物:插件 zip + 说明是否已改 `ExpressServices`。`git diff` 不应含未授权的 `app/`、`crmeb/` 改动。
|
||||
|
||||
安装验证(开发库):
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://127.0.0.1:8011/adminapi/plugin/install' \
|
||||
-H 'Authori-zation: Bearer <token>' \
|
||||
-d 'plugin_key=driver:logistics:your_logistics'
|
||||
```
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
静态(必须):
|
||||
|
||||
- [ ] 目录名 = `Str::studly(driver_key)`;`key` = `driver_key` = `path` 末段
|
||||
- [ ] `php think plugin:manifest-check` 通过
|
||||
- [ ] 驱动内无 `use app\`、`use modules\`、无写业务表
|
||||
- [ ] `query()` 在缺密钥/空单号时行为明确(不抛未捕获异常)
|
||||
- [ ] 清单与代码无真实密钥
|
||||
|
||||
安装后:
|
||||
|
||||
- [ ] `DriverRegistry::scan('logistics')` 可见且 `status=1`(启用且插件未停用)
|
||||
- [ ] 互斥:同类型仅一个启用
|
||||
- [ ] **已改核心或仍在白名单内**:订单物流页出现节点;切换驱动后缓存前缀含新 `driver_key`
|
||||
- [ ] **未改核心**:启用非白名单 key 时接口抛「物流查询驱动不支持」(与预期一致)
|
||||
|
||||
## 12. 常见失败现象
|
||||
|
||||
| 现象 | 可能原因 |
|
||||
| --- | --- |
|
||||
| 后台看不到驱动 | 未安装插件;`owner_plugin_id` 为空;插件未启用 |
|
||||
| `Driver [xxx] not supported.` | 目录名与 `Str::studly(driver_key)` 不一致 |
|
||||
| 启用后查物流报「物流查询驱动不支持」 | `driver_key` 不在白名单;需改 `ExpressServices` |
|
||||
| 永远无轨迹、不报错 | 驱动返回结构未被 `switch` 解析;或命中 `:empty:` 短缓存 |
|
||||
| 切换驱动后轨迹「串台」 | 旧缓存 key 未带 `driver_key`(现役已隔离;勿改回全局 key) |
|
||||
| 一号通报缺少凭证 | `yihaotong_appid`/`secret` 未在后台一号通配置 |
|
||||
| 阿里云始终空列表 | AppCode 未保存到 `config_fields.value`;或 `app_code` 字段名不一致 |
|
||||
|
||||
## 13. 与 express / 一号通的关系
|
||||
|
||||
- 电子面单发货:`ExpressDriverServices::createExpressService()` → 同样走 `crmeb\services\logistics\Express`,类在 `driver/logistics/`。
|
||||
- 同一 `driver_key`(如 `yihaotong`)可同时在 `eb_driver` 存在 `logistics` 与 `express` 两行(不同 `driver_type`),实现类通常两份目录各一份 `Driver.php`,逻辑高度相似。
|
||||
- 纯新增「只查轨迹」第三方:通常只需 `driver/logistics/`,不必建 `express`,除非还要面单。
|
||||
|
||||
## 14. LogisticsDriverServices 与启用
|
||||
|
||||
- 列表/启用/保存配置:`app/services/driver/logistics/LogisticsDriverServices.php`。
|
||||
- `getEnabledDriver()` 返回单行;与 `resolveEnabledQueryDriver()` 配合校验 `owner_plugin_id` 与 `ModuleStateChecker::isDriverActive()`。
|
||||
- 插件停用后即使 `status=1` 也不查第三方,缓存读取同样返回空。
|
||||
|
||||
## 15. 阿里云与一号通 query 参数对照
|
||||
|
||||
| 驱动 | ExpressServices 调用方式 |
|
||||
| --- | --- |
|
||||
| aliyun_express | `query($expressNum, '', $appCode)`,第三参从驱动配置读取 |
|
||||
| yihaotong | `query($expressNum, $com, $phone)`,依赖订单收件手机号与快递公司编码 |
|
||||
|
||||
顺丰等「手机后四位」验单:一号通分支传 `$phone`;阿里云分支看 API 是否支持扩展参数,扩展时仍需改 `ExpressServices` case。
|
||||
|
||||
## 16. 静态扫描命令汇总
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php think plugin:manifest-check
|
||||
php think arch:check # 驱动内禁止 use app/modules
|
||||
```
|
||||
|
||||
安装后:
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
foreach (crmeb\services\driver\DriverRegistry::scan("logistics") as $k => $d) {
|
||||
echo $k, " status=", $d["status"] ?? "-", " class=", crmeb\services\driver\DriverRegistry::getDriverClass("logistics", $k), PHP_EOL;
|
||||
}'
|
||||
```
|
||||
@ -0,0 +1,288 @@
|
||||
# 驱动分册 · 支付(pay)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制:四名字、安装、scan、config_fields),本篇只写 **pay 专属**内容。与 00 重复的部分只给索引,不展开。细节与代码冲突时以代码为准。
|
||||
|
||||
领域归属:第三方协议层 → `backend/driver/pay/{Dir}/`;见 [domain-selection.md](../domain-selection.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 开工前必须告诉用户
|
||||
|
||||
支付是 10 类驱动里**不能纯插件闭环**的类型。以下三处在核心,仅交付 `driver/pay/` 目录做不完,开工前说明并确认处理方式:
|
||||
|
||||
1. **渠道行**:后台「支付方式」读 `eb_pay_channel`。`ManifestDriverInstaller` 只写 `eb_driver`,**不会**自动插入渠道行;`PayChannelServices` 也无「新增渠道」接口。插件不能直接改系统表 DDL。可选:用户同意改安装 SQL `backend/public/install/data/pay_channel_data.sql`;或在驱动包内提供 `Installer.php` 在 `runMigrate()` 幂等 INSERT(需用户授权写系统表数据,仍不走 app 改代码)。
|
||||
2. **服务端分支**:`app/services/pay/PayServices.php` 的 `pay()` / `refund()` 对 `weixin`、`weixin_v3`、`alipay`、`allinpay` 有特殊参数。新 `channel_key` 若需 openid 规则、退款号规则,可能要改这里(零侵入边界例外,须用户同意)。
|
||||
3. **收银台**:`frontend/uni-app/src/components/payment/index.vue` 按 `channel_key` `switch`,无 `default`。新渠道须补分支,否则 `{ status, payInfo }` 返回了也调不起。
|
||||
|
||||
只写驱动时:交付驱动目录 + 交付说明列出上述待办。
|
||||
|
||||
---
|
||||
|
||||
## 2. 文件清单与参考实现
|
||||
|
||||
```text
|
||||
backend/driver/pay/{Dir}/
|
||||
├── plugin.json
|
||||
├── Driver.php # namespace driver\pay\{Dir}; class Driver extends BasePay implements PayInterface
|
||||
├── Installer.php # 可选:插 eb_pay_channel 行、自有表
|
||||
└── extend/ # 可选 SDK,命名空间 driver\pay\{Dir}\extend\...
|
||||
```
|
||||
|
||||
| 参考 | 用途 |
|
||||
| --- | --- |
|
||||
| `driver/pay/AliPay/` | 最小两文件 |
|
||||
| `driver/pay/AllinPay/` | SDK + `PayChannelConfig` 读配置 |
|
||||
| `driver/pay/V3WechatPay/` | 多端、服务商、转账 |
|
||||
|
||||
现役映射:
|
||||
|
||||
| 目录 | driver_key | channel_key(回调 `:type`) |
|
||||
| --- | --- | --- |
|
||||
| AliPay | ali_pay | alipay |
|
||||
| WechatPay | wechat_pay | weixin |
|
||||
| V3WechatPay | v3_wechat_pay | weixin_v3(别名 `v3wechat`) |
|
||||
| AllinPay | allin_pay | allinpay(别名 `allin*` 前缀) |
|
||||
|
||||
目录名 `{Dir}` 必须等于 `Str::studly(driver_key)`(见 00 总览 §3.2)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 注册与配置(pay 特有,摘要)
|
||||
|
||||
- **安装期**:`plugin.json` → `ManifestDriverInstaller` → `eb_driver` 一行(`driver_type=pay`)。新驱动默认 `status=0`,后台须启用。
|
||||
- **运行时**:`DriverRegistry::scan('pay')` **只读表**,不扫目录。
|
||||
- **配置值**:`PayChannelConfig::get($channelKey)` 读 `eb_pay_channel.config_fields`(field→value)。禁止写 `system_config`。
|
||||
- **配置结构**:`PayChannelServices::getDetail()` 以驱动清单 `config_fields` 为 schema,库内只存 value(升级改 `show_if` 不必迁 JSON)。
|
||||
|
||||
`initialize($config)` 在 `pay()`/`refund()` 路径下 **`$config` 为空**;四个现役驱动都在 `initialize` 里调 `PayChannelConfig::get()`,不要依赖构造注入的配置。
|
||||
|
||||
---
|
||||
|
||||
## 4. plugin.json(driver 子对象)
|
||||
|
||||
顶层 `key` 与 `driver.driver_key` 保持一致(见 00)。pay 额外字段(**不进 eb_driver**,`PayServices` 运行时读清单):
|
||||
|
||||
| 字段 | 作用 |
|
||||
| --- | --- |
|
||||
| `channel_key` | 业务支付方式 ID;`eb_pay_channel.channel_key`、notify URL、退款路由 |
|
||||
| `platform` | `wechat` / `alipay` / `allinpay` 等 |
|
||||
| `capabilities` | `pay`、`refund`、`notify`、`merchant_pay`、`merchant_pay_new`、`query_transfer`、`transfer_notify` |
|
||||
| `pay_type_map` | 端 → 供应商 pay_type(`wechat_mini`、`h5`、`app`、`pc`…) |
|
||||
| `pay_status_type` | 返回前端 `status`,可字符串或按端对象 |
|
||||
| `supported_platforms` | 端中文名 |
|
||||
| `required_credentials` | 如 `{"wechat_mini":["openid"]}` → `PayCredentialService` 调 channel `getOpenid` |
|
||||
| `default_options` | 发起支付并入 `$options` 的字段名 |
|
||||
| `pay_urls` | 固定跳转 URL(通联等) |
|
||||
|
||||
`capabilities` 影响业务分支(如 `merchant_pay_new` 与抽奖转账)。未声明则 `hasCapability()` 为 false。
|
||||
|
||||
模板见下(与现役结构一致):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_pay",
|
||||
"name": "某某支付",
|
||||
"type": "driver",
|
||||
"path": "driver/pay/YourPay",
|
||||
"version": "1.0.0",
|
||||
"driver": {
|
||||
"driver_key": "your_pay",
|
||||
"channel_key": "yourpay",
|
||||
"platform": "yourpay",
|
||||
"capabilities": ["pay", "refund", "notify"],
|
||||
"config_fields": [
|
||||
{"field": "yourpay_mch_id", "label": "商户号", "type": "text", "required": true, "group": "商户"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Driver.php(PayInterface 六方法)
|
||||
|
||||
```php
|
||||
protected function initialize(array $config)
|
||||
{
|
||||
$this->payment = PayChannelConfig::get('yourpay');
|
||||
}
|
||||
|
||||
public function create(...) { $this->authSetPayType($options); /* 调第三方 */ }
|
||||
public function merchantPay(...) { return false; }
|
||||
public function refund(...) { /* 注意 PayServices 传入的是退款单号还是 trade_no */ }
|
||||
public function queryRefund(...) { /* 对齐 normalizeRefundResult 期望结构 */ }
|
||||
public function handleNotify(string $type = '')
|
||||
{
|
||||
// 验签失败 → response('fail', 500) 等非 2xx
|
||||
Event::until('Notify', [[
|
||||
'attach' => $attach,
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'transaction_id' => $transactionId,
|
||||
], 'yourpay']);
|
||||
}
|
||||
```
|
||||
|
||||
纪律:
|
||||
|
||||
- 不 `use app\` / `use modules\`;不改订单表。
|
||||
- 成功入账只走 `Notify` 事件 → `NotifyListener`。
|
||||
- 回调路由已存在:`Route::any('pay/notify/:type', ...)`,`resolveChannelKeyByType()` 支持别名(`v3wechat`→`weixin_v3`,`allin*`→`allinpay`)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 三条链路(逐步)
|
||||
|
||||
### 6.1 发起支付
|
||||
|
||||
1. 用户端提交 → `OrderPayServices`(按 `channel_key` switch 准备 openid 等)。
|
||||
2. `PayServices::pay()` → `resolveDriverName()`(可读 `version_switch`)→ `resolvePayType()`(优先 `eb_pay_channel_platform`)。
|
||||
3. `app()->make(Pay::class, [$driverName])` → `Driver::create()`。
|
||||
4. 返回 `{ status, payInfo }` → uni-app `payment/index.vue`(**按 channel_key 分支**,不是只看 status)。
|
||||
|
||||
### 6.2 异步回调
|
||||
|
||||
1. `PayController::notify` → `PayServices::handleNotify($type)`。
|
||||
2. 解析 channel → 实例化 Driver → `handleNotify()`。
|
||||
3. 驱动验签 → `Event::until('Notify', ...)`。
|
||||
4. 应答:失败 **非 2xx**;成功按协议返回。
|
||||
|
||||
### 6.3 退款
|
||||
|
||||
`PayServices::refund()` 对微信/支付宝/通联有不同参数整理;微信 V3 校验支付与退款 `channel_key` 一致。上层建议 `submitRefund()` + `queryRefundStatus()`:**UNKNOWN 不能换号重提**。
|
||||
|
||||
分销提现等「付款到零钱」走 `merchantPay`,需声明 capability 且可能涉及核心分支评估。
|
||||
|
||||
---
|
||||
|
||||
## 7. eb_pay_channel 渠道行(Installer 思路)
|
||||
|
||||
支付方式页列表来自 **已有** `eb_pay_channel` 行。仅装驱动不会出现新支付方式。
|
||||
|
||||
驱动包可选 `Installer.php`:
|
||||
|
||||
- `getKey()` → `driver:pay:{driver_key}`
|
||||
- `getType()` → `driver`
|
||||
- `runMigrate()`:若不存在则 INSERT 一行(`channel_key`、`channel_name`、`driver`=**目录名** `YourPay`、`config_fields` 空数组或默认 schema)
|
||||
- `rollbackMigrate()`:按 `channel_key` 删除(仅卸载驱动时,生产慎用)
|
||||
|
||||
`driver` 列存**目录名**,不是 FQCN。骨架参照 `backend/driver/sms/yunxin/Installer.php`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端与凭证
|
||||
|
||||
三层对齐缺一会「后台能配、端上调不起」:
|
||||
|
||||
1. `supported_platforms` + `eb_pay_channel_platform` 各端开关与 `pay_type`
|
||||
2. `pay_type_map` 默认 pay_type
|
||||
3. `pay_status_type` → 接口 `status`;收银台仍要 **switch(channel_key)** 分支
|
||||
|
||||
`PayCredentialService::getCredentials($uid, $channelKey)` 读 `required_credentials`,反射调用对应 **channel** 模块 `App::getOpenid($uid)`(见 [02-模块-channel](02-模块-channel-用户渠道.md))。
|
||||
|
||||
---
|
||||
|
||||
## 9. 逐步落地(新 pay 驱动)
|
||||
|
||||
1. 定 `driver_key`、`channel_key`、目录名 `Str::studly(driver_key)`。
|
||||
2. 复制 `AliPay` 或 `AllinPay` 改 namespace。
|
||||
3. 写 `plugin.json`(六方法能力 + config_fields)。
|
||||
4. 实现 `Driver` 六方法 + `PayChannelConfig::get`。
|
||||
5. (可选)`Installer` 插 `eb_pay_channel` 行。
|
||||
6. 后台安装驱动 → 启用 → 填配置 → `php think pay:clear-cache`。
|
||||
7. 与用户确认 core 三处(§1)处理方案。
|
||||
8. 沙箱:拉起支付 → 回调 → 退款 → `queryRefund`。
|
||||
9. `php think plugin:manifest-check`;打包 `driver/pay/{Dir}/` → `.plugin-packages/`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收
|
||||
|
||||
**静态**
|
||||
|
||||
- [ ] `php -l`;`plugin:manifest-check`
|
||||
- [ ] 四名字对齐;无 app/modules use;无真实密钥
|
||||
- [ ] `handleNotify` 验签失败非 2xx;成功只 `Notify` 一次
|
||||
|
||||
**联调(隔离库 + 沙箱)**
|
||||
|
||||
- [ ] `DriverRegistry::scan('pay')` 含 `driver_key` 且 `status=1`
|
||||
- [ ] `eb_pay_channel` 有行;支付方式页可保存
|
||||
- [ ] 各端拉起;重复回调不重复入账
|
||||
- [ ] 退款与 `queryRefund` 终态正确
|
||||
- [ ] notify URL:`{site_url}/api/pay/notify/{channel_key}`
|
||||
|
||||
---
|
||||
|
||||
## 11. 排障(精选)
|
||||
|
||||
| 现象 | 处理 |
|
||||
| --- | --- |
|
||||
| scan 看不到驱动 | 未安装/未启用/owner_plugin_id 过滤 |
|
||||
| 支付方式页没有 | 缺 `eb_pay_channel` 行 |
|
||||
| `Driver not supported` | 目录名与 studly(driver_key) |
|
||||
| 有 payInfo 端上无反应 | 补 `payment/index.vue` switch |
|
||||
| 回调 200 但订单未付 | 未走 Notify;out_trade_no 不一致 |
|
||||
| 改配置不生效 | `php think pay:clear-cache` |
|
||||
| 退款渠道不一致 | 用支付记录里的 channel_key |
|
||||
|
||||
---
|
||||
|
||||
## 12. 打包与禁止事项
|
||||
|
||||
- 交付物:`backend/driver/pay/{Dir}/` 整目录 zip 到 `.plugin-packages/`(见 [插件开发规范 §7](../../../../rules/插件开发规范.md))。
|
||||
- 禁止:驱动内改订单/退款状态;密钥入库/入 git;支付配置写 `system_config`。
|
||||
- 安全:[安全红线](../../../../rules/安全红线.md) 支付验签与幂等。
|
||||
|
||||
---
|
||||
|
||||
## 13. 与 00 总览的分工(避免重复阅读)
|
||||
|
||||
| 主题 | 读哪里 |
|
||||
| --- | --- |
|
||||
| 四名字、studly、ManifestDriverInstaller、scan 不写库 | [00-总览 §3](00-总览-类型路由与边界.md) |
|
||||
| config_fields 的 type/show_if/password | [00-总览 §3.5](00-总览-类型路由与边界.md) |
|
||||
| plugin:manifest-check、安装 API、arch:check | [00-总览 §5](00-总览-类型路由与边界.md) |
|
||||
| pay 渠道行、PayServices 分支、收银台 switch | **本篇 §1、§6、§8** |
|
||||
| 排障 | 本篇 §11 |
|
||||
|
||||
---
|
||||
|
||||
## 14. PayChannelConfig 例外说明
|
||||
|
||||
`crmeb/services/pay/PayChannelConfig.php` 在 crmeb 层直查 `pay_channel` 表,属于架构允许的例外(类注释明确禁止「改成 app DAO」)。驱动读配置**只走此类**,不要在驱动内 `Db::name('pay_channel')` 复制第二套逻辑。
|
||||
|
||||
字段合并:`config_fields` 为空时,部分路径会回读 `driver/pay/{Dir}/plugin.json` 的字段定义作 schema(全新安装未保存过配置时),与 `PayChannelServices::mergeConfigFields` 后台行为一致。
|
||||
|
||||
---
|
||||
|
||||
## 15. capabilities 与业务调用示例
|
||||
|
||||
声明在 `plugin.json` → 运行时 `DriverRegistry` 解码 → `PayServices::hasCapability($platform, $cap)`。
|
||||
|
||||
| capability | 典型调用方 |
|
||||
| --- | --- |
|
||||
| `pay` / `refund` / `notify` | 基础支付闭环 |
|
||||
| `merchant_pay` / `merchant_pay_new` | 分销提现、抽奖转账等 |
|
||||
| `query_transfer` / `transfer_notify` | 转账查询与回调 |
|
||||
|
||||
未声明 `merchant_pay_new` 时,业务不会走 V3 转账新接口——这不是驱动 bug,是清单能力未开放。
|
||||
|
||||
---
|
||||
|
||||
## 16. 静态命令(pay 相关)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/pay/YourPay/Driver.php
|
||||
php think plugin:manifest-check
|
||||
php think pay:clear-cache # 改 eb_pay_channel 后
|
||||
php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
foreach (crmeb\services\driver\DriverRegistry::scan("pay") as $k=>$d)
|
||||
echo $k, " status=", $d["status"], PHP_EOL;'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 小结
|
||||
|
||||
支付二开稳态 = **安装写 eb_driver** + **PayChannelConfig 自取配置** + **补 eb_pay_channel 行与收银台分支** + **Notify 闭环**。驱动只负责第三方协议,业务结果由既有 `NotifyListener` 完成。
|
||||
@ -0,0 +1,279 @@
|
||||
# 驱动分册 · 云打印(printer)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写订单小票云打印专属内容。
|
||||
|
||||
## 1. 选型判定
|
||||
|
||||
| 需求 | 落本类型 |
|
||||
| --- | --- |
|
||||
| 订单支付/下单后向易联云、飞鹅等云打印机推小票 | `backend/driver/printer/{Dir}/` |
|
||||
| 电子面单(快递单) | [express 分册](01-驱动-express-电子面单.md) |
|
||||
| 本地 USB 打印 | 不在 driver/printer 范畴 |
|
||||
|
||||
云打印驱动只负责:接收**已渲染好的**打印指令字符串,调用厂商 API 提交;小票模板、订单字段组装在 `app/services/driver/printer/SystemTicketServices.php`。
|
||||
|
||||
## 2. 开工前必须告诉用户
|
||||
|
||||
小票链路在核心**写死驱动目录名**(Studly,不是 snake_case `driver_key`):
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --- | --- |
|
||||
| `SystemTicketServices::startPrint()` | `if (!in_array($name, ['YiLianYun', 'FeiEYun'], true)) throw 不支持的打印驱动` |
|
||||
| `SystemTicketServices::ticketForm()` | 平台单选只有「易联云 / 飞鹅云」,字段名 `yly_*` / `fey_*` |
|
||||
| 同文件 `ylyContent()` / `feyContent()` | 易联云 `<FS2>` 标签 vs 飞鹅 `<CB><BR>` 标签 |
|
||||
|
||||
新云打印厂商要上线,需用户同意扩展:`startPrint` 白名单、表单 `appendControl`、内容渲染分支(或抽象为配置化模板)。仅交付 `driver/printer/` 目录无法在小票流程中被选中。
|
||||
|
||||
另:`printer` 驱动的 `driver_key` 现役与目录名相同(`YiLianYun`、`FeiEYun`),`Printer` 门面用 `Str::studly($name)` 解析类。
|
||||
|
||||
## 3. 文件清单
|
||||
|
||||
```text
|
||||
backend/driver/printer/{Dir}/
|
||||
├── plugin.json
|
||||
└── Driver.php # namespace driver\printer\{Dir}; extends BasePrinter
|
||||
```
|
||||
|
||||
| 组件 | 路径 |
|
||||
| --- | --- |
|
||||
| 基类 | `crmeb\services\printer\BasePrinter` |
|
||||
| 门面 | `crmeb\services\printer\Printer` |
|
||||
| Token 封装 | `crmeb\services\printer\AccessToken`(构造时传入 `$config`) |
|
||||
|
||||
现役:
|
||||
|
||||
| 目录 | driver_key | plugin.json 额外字段 |
|
||||
| --- | --- | --- |
|
||||
| `YiLianYun/` | `YiLianYun` | `driver.driver` = `YiLianYun` |
|
||||
| `FeiEYun/` | `FeiEYun` | 同上 |
|
||||
|
||||
**推荐照抄**:`FeiEYun/` 或 `YiLianYun/`(均含 `startPrinter()`、`setPrinterContent()`)。
|
||||
|
||||
## 4. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "YourCloudPrint",
|
||||
"name": "某某云打印",
|
||||
"description": "某某云打印驱动",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/printer/YourCloudPrint",
|
||||
"default_install": false,
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某云打印",
|
||||
"driver_key": "YourCloudPrint",
|
||||
"driver": "YourCloudPrint",
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "yourcloud_api_key",
|
||||
"label": "API Key",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "基础配置"
|
||||
},
|
||||
{
|
||||
"field": "yourcloud_api_secret",
|
||||
"label": "API Secret",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"group": "基础配置"
|
||||
}
|
||||
],
|
||||
"capabilities": {
|
||||
"device_id": {
|
||||
"label": "设备编号",
|
||||
"tip": "在「打印设置」中填写 printer_id"
|
||||
}
|
||||
},
|
||||
"help_doc": {
|
||||
"title": "使用说明",
|
||||
"steps": ["注册开放平台", "填写 API 密钥", "添加打印设备并绑定驱动名 YourCloudPrint"],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `driver.driver` 与 `driver_key`、目录名保持一致(历史契约,打印设备表 `driver` 列存此名)。
|
||||
- `capabilities.device_id` 供后台展示设备 SN 说明;业务不强制解析。
|
||||
|
||||
## 5. Driver.php 方法契约
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\printer\YourCloudPrint;
|
||||
|
||||
use crmeb\services\printer\BasePrinter;
|
||||
|
||||
class Driver extends BasePrinter
|
||||
{
|
||||
protected function initialize(array $config): void {}
|
||||
|
||||
public function setPrinterContent($content, $times = 1): self
|
||||
{
|
||||
$this->times = $times;
|
||||
$this->printerContent = $content;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function startPrinter()
|
||||
{
|
||||
if (!$this->printerContent) {
|
||||
return $this->setError('Missing print');
|
||||
}
|
||||
// 调厂商 API;成功返回 array(含任务 id);失败 setError 返回 false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.1 必填方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
| --- | --- |
|
||||
| `setPrinterContent(string $content, int $times)` | 链式设置内容与联数 |
|
||||
| `startPrinter()` | 提交打印;易联云返回 `['body'=>['id'=>...]]`;飞鹅返回 `['data'=>...]` |
|
||||
|
||||
### 5.2 基类能力
|
||||
|
||||
- `setJobId($key)`:任务去重,重试沿用(`SystemTicketServices` 传入 task hash)。
|
||||
- `isSubmissionUncertain()` / `getError()`:提交结果不明时避免重复打单。
|
||||
- 构造:`(string $name, AccessToken $accessToken, string $configFile)`,`AccessToken` 从 `$config` 映射字段(见 `createPrinter` 传入的 `$configData`)。
|
||||
|
||||
### 5.3 SystemTicketServices 传入的配置键
|
||||
|
||||
| 驱动名 | `$configData` 键 |
|
||||
| --- | --- |
|
||||
| `YiLianYun` | `partner`, `clientId`, `apiKey`, `terminal`(来自 `yly_user_id` 等 + 设备 `printer_id`) |
|
||||
| `FeiEYun` | `feyUser`, `feyUkey`, `feySn` |
|
||||
|
||||
新驱动若接入核心,需在此映射增加分支。
|
||||
|
||||
## 6. 配置与数据表
|
||||
|
||||
| 存储 | 用途 |
|
||||
| --- | --- |
|
||||
| `eb_driver`(printer) | 驱动级 API 账号 |
|
||||
| `eb_system_ticket` / 打印设备表(见 `PrinterChannelDao`) | 设备 `driver` 列、`printer_id`、`times`、`print_content` 模板 JSON |
|
||||
| `eb_printer_task` | 提交去重、状态 `pending/submitted/failed/uncertain` |
|
||||
|
||||
设备级凭据(如 SN)在「小票打印」菜单配置,不在 `plugin.json`。
|
||||
|
||||
## 7. 调用链
|
||||
|
||||
```text
|
||||
支付/下单事件
|
||||
→ PrintJob / StoreIntegralOrderServices 等
|
||||
→ SystemTicketServices::startPrint($order, $product, $print_type)
|
||||
→ 查启用设备 → 按 driver 分组读 eb_driver 配置
|
||||
→ ylyContent / feyContent 渲染 HTML 标签串
|
||||
→ new Printer($name, $configData)
|
||||
→ setJobId → setPrinterContent($content, $times) → startPrinter()
|
||||
→ PrinterTaskDao 记录 submitted / failed
|
||||
```
|
||||
|
||||
手动打印:`$print_type === true` 打全部启用设备。
|
||||
|
||||
## 8. 逐步开发流程
|
||||
|
||||
1. 定目录名 `{Dir}`(即将来 `eb_printer_channel.driver` 的值)。
|
||||
2. 实现 `Driver.php`:对照厂商 API 文档完成 `startPrinter()`。
|
||||
3. 编写 `plugin.json`,`key`/`driver_key`/`driver` 与目录一致。
|
||||
4. 静态检查;安装并启用驱动插件。
|
||||
5. **未改核心**:用脚本直接 `new Printer('{Dir}', $config)` 测通 API。
|
||||
6. **已改核心**:扩展 `startPrint`、`ticketForm`、`xxxContent`;后台添加设备并选新驱动。
|
||||
7. 下单/支付测自动打印;看 `eb_printer_task` 状态与打印机实收。
|
||||
|
||||
## 9. 参考实现
|
||||
|
||||
- `driver/printer/YiLianYun/Driver.php`:授权刷新、error 18/14 重试、`submissionUncertain`。
|
||||
- `driver/printer/FeiEYun/Driver.php`:飞鹅签名与 SN。
|
||||
- `app/services/driver/printer/SystemTicketServices.php`:`ylyContent`/`feyContent` 字段与订单快照。
|
||||
|
||||
## 10. 打包(`.plugin-packages/`)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/printer/YourCloudPrint/Driver.php
|
||||
php think plugin:manifest-check
|
||||
zip -r ../.plugin-packages/driver-printer-YourCloudPrint-1.0.0.zip driver/printer/YourCloudPrint/
|
||||
```
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
- [ ] `driver_key` 与目录名一致(打印类常用 Studly PascalCase)
|
||||
- [ ] `setPrinterContent` + `startPrinter` 可用;失败走 `setError`
|
||||
- [ ] 无 `use app\`
|
||||
- [ ] `DriverRegistry::scan('printer')` 可见
|
||||
- [ ] **改核心后**:后台可选新平台;支付后小票任务 `submitted`
|
||||
- [ ] 重复回调/重试不重复打单(task_key 去重)
|
||||
|
||||
## 12. 常见失败现象
|
||||
|
||||
| 现象 | 原因 |
|
||||
| --- | --- |
|
||||
| 配置了驱动但从不出票 | 设备 `driver` 不在 YiLianYun/FeiEYun 白名单 |
|
||||
| `小票订单号或打印时机无效` | `print_type` 非 1/2/true |
|
||||
| `打印机设备ID为空` | 设备未填 SN/`printer_id` |
|
||||
| `云打印提交失败` | 返回非 array;密钥错;标签内容非法 |
|
||||
| 任务 `uncertain` | 网络超时但可能已打单;勿盲目重发 |
|
||||
| `Driver [yi_lian_yun] not supported` | 门面 default 配置与目录名不一致;应传 `YiLianYun` |
|
||||
| 设备配置与驱动表字段对不上 | `yly_*`/`fey_*` 字段名固定 |
|
||||
|
||||
## 13. 与 express 云打印机 siid
|
||||
|
||||
电子面单 `siid`(快递云打印)在 express 配置链,与小票 `driver/printer` 无关。勿混淆 `config_export_siid` 与小票设备 SN。
|
||||
|
||||
## 14. 打印模板 JSON(`print_content`)
|
||||
|
||||
设备表存储的模板经 `PrinterChannelServices::normalizePrintContent()` 归一化,常见键:
|
||||
|
||||
| 键 | 含义 |
|
||||
| --- | --- |
|
||||
| `header` | 页眉店铺名 |
|
||||
| `delivery` | 配送/收货信息块 |
|
||||
| `buyer_remarks` | 买家备注 |
|
||||
| `goods` | 数组,含 `0` 表格 / `1` 规格编码等 |
|
||||
| `pay` / `order` | 支付项、订单号/时间项 |
|
||||
| `code` / `code_url` | 二维码 |
|
||||
| `show_notice` / `notice_content` | 底部公告 |
|
||||
|
||||
`ylyContent`/`feyContent` 在 `SystemTicketServices` 内硬编码渲染逻辑;新驱动若接入核心,需复制一份标签规则或抽象共享。
|
||||
|
||||
## 15. 队列与触发时机
|
||||
|
||||
| print_type | 含义 |
|
||||
| --- | --- |
|
||||
| `1` | 支付后(默认) |
|
||||
| `2` | 下单后 |
|
||||
| `true` | 手动打印全部启用设备 |
|
||||
|
||||
`PrintJob` 队列消费时组装 `$order`、`$product` 快照;字段缺失会导致模板空白但不会选错驱动。
|
||||
|
||||
## 16. 本地验证
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
$p = new \crmeb\services\printer\Printer("YiLianYun", ["partner"=>"","clientId"=>"","apiKey"=>"","terminal"=>""]);
|
||||
echo get_class($p), PHP_EOL;'
|
||||
```
|
||||
|
||||
替换为 `FeiEYun` 与对应 config 键名测类加载。完整 `startPrinter` 需有效云端账号。
|
||||
|
||||
## 17. 核心改造最小集合(用户同意后)
|
||||
|
||||
- [ ] `startPrint()` 白名单增加 `{Dir}`
|
||||
- [ ] `ticketForm()` 增加 radio 分支与表单字段
|
||||
- [ ] 新增 `{dir}Content()` 或参数化标签引擎
|
||||
- [ ] `createPrinter()` 传入的 `$configData` 键与 `AccessToken`/驱动一致
|
||||
- [ ] 文档告知运维在「打印设置」选新 `driver` 列值
|
||||
|
||||
## 18. AccessToken 与配置映射
|
||||
|
||||
`Printer` 门面构造 `AccessToken($name, $configFile, $config)`;易联云/飞鹅在 `AccessToken` 内按 `$name` 分支读取 `partner`/`feyUser` 等。新驱动通常要在 `crmeb/services/printer/AccessToken.php` 增加分支——这又属于核心改动,需单独授权;或驱动在 `initialize()` 内忽略 AccessToken 自行签名(与现役模式差异较大,优先与用户确认架构)。
|
||||
@ -0,0 +1,350 @@
|
||||
# 驱动分册 · 短信(sms)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制),本篇只写短信专属内容。
|
||||
|
||||
## 1. 何时用 / 何时不用
|
||||
|
||||
**适合做成 sms 驱动:**
|
||||
|
||||
- 商城通知、验证码、营销短信等,业务侧只关心「手机号 + 模板编号 + 变量」,规则不变,只是换一家短信网关。
|
||||
- 调用方已经固定:`SmsChannel`(通知管线 + 登录验证码)、少数业务直调 `SmsChannel::pushSms()`。
|
||||
|
||||
**不要做成 sms 驱动:**
|
||||
|
||||
- 要在后台做「模板申请 / 签名审核 / 发送记录查询」且希望走统一 UI → 一号通在 `serve/SmsController` 里写死了 `yihaotong`,新驱动没有这套页面(见第 2 节)。
|
||||
- 需求是改通知编排、变量来源、谁收短信 → 改 `app/services/message/`,不是驱动。
|
||||
- 国际短信、多签名按店铺切换、同一模板多驱动并行 → 当前模型是**全局互斥单驱动**,需先问用户是否接受产品约束或改核心。
|
||||
|
||||
## 2. 开工前必须问用户的核心写死点
|
||||
|
||||
短信属于可零侵入接入的类型,但下面几项会直接影响 `Driver.php` 和通知配置,开工前必须确认:
|
||||
|
||||
| 写死点 | 为何要问 | 代码依据 |
|
||||
| --- | --- | --- |
|
||||
| 模板变量是**命名**还是**位置**参数 | 阿里云/一号通用 JSON 命名参数;腾讯云在 `SmsChannel::doSend()` 里对 `driver_key === 'tencent'` 会 `array_values()` 转成位置数组 | `app/services/message/channel/SmsChannel.php` |
|
||||
| 模板 ID 由谁维护 | 管理员在「通知设置 → 短信渠道」手工填 `sms_id`;没有驱动内拉模板列表的通用 API(一号通除外) | `NoticeReadiness::reasons()` |
|
||||
| 切换驱动后旧模板是否作废 | 模板 extra 带 `sms_driver_key`,与当前启用驱动不一致则拒绝发送 | `SmsChannel::doSend()`、`NoticeReadiness` |
|
||||
| 是否依赖宿主 `vendor/` SDK | 阿里云驱动直接用 `AlibabaCloud\SDK\Dysmsapi`(已在宿主 composer);腾讯云自签 HTTP。新驱动选 SDK 还是 `extend/` 自封装 | `driver/sms/Aliyun/Driver.php`、`driver/sms/Tencent/Driver.php` |
|
||||
| 一号通相关能力要不要 | `adminapi/serve/SmsController` 全部 `app()->make(Sms::class, [yihaotong_config()])`,与启用驱动无关 | `app/adminapi/controller/serve/SmsController.php` |
|
||||
|
||||
**仍写死在核心、插件改不了的分支(交付时说明即可):**
|
||||
|
||||
- `Sms::invokeClass()` 总会构造 `AccessTokenServeService` 并注入构造函数,即使你的驱动不用一号通。
|
||||
- 历史种子数据里 `sms_driver_key` 为空时,`NoticeSeedServices` 会默认补成 `yihaotong`,迁移环境切换驱动后要重配通知模板。
|
||||
- `driver_key === 'yihaotong'` 时,`yihaotong_appid` / `yihaotong_appsecret` 读 `sys_config`,不是 `eb_driver.config_fields` 里保存的值。
|
||||
|
||||
## 3. 运行时链路(代码定位)
|
||||
|
||||
```text
|
||||
通知 / 验证码
|
||||
→ SmsChannel::send() / pushSms()
|
||||
→ SmsDriverServices::getEnabledDriver() # eb_driver,driver_type=sms,status=1,互斥仅一条
|
||||
→ config_fields 摊平为 field => value
|
||||
→ app()->make(Sms::class, [$driverKey, $config])
|
||||
→ crmeb\services\sms\Sms::resolveClass() # Str::studly($driverKey) → driver\sms\{Dir}\Driver
|
||||
→ Driver::send($phone, $templateId, $data)
|
||||
```
|
||||
|
||||
门面与基类:
|
||||
|
||||
- 门面:`backend/crmeb/services/sms/Sms.php`(`namespace` 前缀 `\driver\sms\`)
|
||||
- 基类:`backend/crmeb/services/sms/BaseSms.php`(继承 `BaseStorage`,**无**抽象 `send`,但业务只调 `send`)
|
||||
- 管理:`backend/app/services/driver/sms/SmsDriverServices.php`
|
||||
- 后台路由:`backend/app/adminapi/route/driver.php` 下 `sms_driver/*`
|
||||
|
||||
## 4. 目录与命名
|
||||
|
||||
```text
|
||||
backend/driver/sms/{Dir}/
|
||||
├── plugin.json # 必须
|
||||
├── Driver.php # 必须:namespace driver\sms\{Dir}; class Driver extends BaseSms
|
||||
├── Installer.php # 可选:仅自有表时(参考 yunxin,目录名小写是反例)
|
||||
└── extend/ # 可选:自封装 HTTP/SDK
|
||||
```
|
||||
|
||||
| 名字 | 规则 | 反例 |
|
||||
| --- | --- | --- |
|
||||
| `{Dir}` | 必须等于 `Str::studly(driver_key)`,如 `your_sms` → `YourSms`,`aliyun` → `Aliyun` | `driver/sms/yunxin/` 小写目录,Linux PSR-4 易失败 |
|
||||
| `plugin.json` → `path` | `driver/sms/{Dir}` 与磁盘一致 | |
|
||||
| 顶层 `key` 与 `driver.driver_key` | 建议相同 | 不一致时不报错,排查用复合 ID `driver:sms:{key}` |
|
||||
|
||||
确认 studly:
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; echo \think\helper\Str::studly("your_sms"), PHP_EOL;'
|
||||
```
|
||||
|
||||
## 5. plugin.json 完整示例与字段表
|
||||
|
||||
第三方驱动:`default_install: false`(或不写),`is_builtin` 不写或 `false`,装完默认 `status=0`。
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_sms",
|
||||
"name": "某某短信",
|
||||
"description": "某某短信网关驱动",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/sms/YourSms",
|
||||
"default_install": false,
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某短信",
|
||||
"driver_key": "your_sms",
|
||||
"class_name": "Driver",
|
||||
"icon": "icon-sms",
|
||||
"sort": 50,
|
||||
"capabilities": ["send"],
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_sms_sign_name",
|
||||
"label": "短信签名",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "基础配置",
|
||||
"tip": "在短信控制台审核通过的签名"
|
||||
},
|
||||
{
|
||||
"field": "your_sms_access_key",
|
||||
"label": "AccessKey",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "密钥配置"
|
||||
},
|
||||
{
|
||||
"field": "your_sms_secret",
|
||||
"label": "Secret",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"group": "密钥配置"
|
||||
}
|
||||
],
|
||||
"help_doc": {
|
||||
"title": "某某短信接入说明",
|
||||
"steps": [
|
||||
"在短信控制台创建签名与模板",
|
||||
"获取 API 密钥并填入上方配置",
|
||||
"启用驱动后,在通知设置中为每条模板填写服务商模板编号"
|
||||
],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**顶层字段(与总览一致,短信无额外顶层字段):**
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `key` | 是 | 插件标识,建议与 `driver_key` 相同 |
|
||||
| `name` | 是 | 后台显示名 |
|
||||
| `type` | 是 | 固定 `driver` |
|
||||
| `version` | 是 | 首版阶段固定 `1.0.0` |
|
||||
| `path` | 是 | 相对 `backend/`,且 realpath 必须存在 |
|
||||
| `require` | 建议 | 宿主 PHP / CRMEB 版本 |
|
||||
| `default_install` | 否 | 需用户密钥的第三方写 `false` |
|
||||
|
||||
**`driver` 子对象(安装时写入 `eb_driver`,见第 7 节):**
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `driver_key` | 是 | 运行时 `$type`,`(driver_type, driver_key)` 唯一 |
|
||||
| `name` | 否 | 缺省用顶层 `name` |
|
||||
| `class_name` | 否 | 固定 `Driver` |
|
||||
| `icon` / `sort` | 否 | 列表展示;`sort` 越大越靠前 |
|
||||
| `is_builtin` | 否 | 第三方勿写 `true`;为真时安装策略见总览 3.4 |
|
||||
| `capabilities` | 建议 | 短信至少声明 `["send"]`;一号通还声明 `template`、`record` 等,**业务不会因此调用** |
|
||||
| `config_fields` | 是 | 后台动态表单;保存后每项多 `value` |
|
||||
| `help_doc` | 建议 | `title` / `steps` / `link`,后台帮助区展示 |
|
||||
|
||||
`config_fields` 单字段:`field`(建议 `{driver_key}_` 前缀)、`label`、`type`(`text`/`password`/…)、`required`、`group`、`tip`、`default`、`show_if`。
|
||||
|
||||
## 6. Driver.php 契约
|
||||
|
||||
### 6.1 必须实现
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\sms\YourSms;
|
||||
|
||||
use crmeb\services\sms\BaseSms;
|
||||
|
||||
class Driver extends BaseSms
|
||||
{
|
||||
protected function initialize(array $config = [])
|
||||
{
|
||||
parent::initialize($config);
|
||||
// $config 键名 = config_fields 的 field
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $phone 收件手机号
|
||||
* @param string $templateId 通知模板 extra 中的 sms_id(服务商模板编号)
|
||||
* @param array $data 模板变量;SmsChannel 已按模板内容 {var} 过滤
|
||||
* @return array|false 成功建议含 id/content/template;失败 return $this->setError('原因')
|
||||
*/
|
||||
public function send(string $phone, string $templateId, array $data = [])
|
||||
{
|
||||
if ($phone === '') {
|
||||
return $this->setError('手机号不能为空');
|
||||
}
|
||||
// 调网关;失败:return $this->setError('服务商原文或可读原因');
|
||||
return [
|
||||
'id' => '',
|
||||
'content' => json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||
'template' => $templateId,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**构造函数不要覆写。** 门面注入签名为:
|
||||
|
||||
```php
|
||||
// crmeb\services\sms\Sms::invokeClass()
|
||||
Container::getInstance()->invokeClass($class, [
|
||||
$this->name,
|
||||
$handleAccessToken, // AccessTokenServeService
|
||||
$this->configFile,
|
||||
$this->config,
|
||||
]);
|
||||
```
|
||||
|
||||
### 6.2 可选方法(仅一号通后台会用)
|
||||
|
||||
现役 `driver/sms/Aliyun/Driver.php` 有空实现的 `open()`、`modify()`、`temps()` 等;**通知发送不会调用**。若不做一号通式后台,不必实现。
|
||||
|
||||
### 6.3 参数格式要点
|
||||
|
||||
- `SmsChannel::parseData()` 按通知模板内容里的 `{变量名}` 从 `$data` 取**命名**键;模板内容为空则原样透传 `$data`。
|
||||
- `driver_key === 'tencent'` 时,渠道层会把 `$data` 转为**有序位置数组**再交给驱动;新驱动若也是位置参数,应在驱动内自己 `array_values()`,不要指望改核心加分支。
|
||||
- 腾讯云驱动 `send()` 成功时返回数组而非统一结构;渠道只判断 `!== false`,不解析返回值。
|
||||
|
||||
### 6.4 错误处理约定
|
||||
|
||||
- 通知路径:`send()` 返回 `false` → `SmsChannel` 读 `getError()` 抛 `NoticeSendException`。
|
||||
- 验证码路径:`pushSms()` 捕获异常记日志并返回 `false`。
|
||||
- **不要**像阿里云驱动那样在 `send()` 里抛 `ApiException`(会绕过 `setError`,上层表现不一致)。应 `return $this->setError(...)`。
|
||||
|
||||
## 7. 配置存在哪张表
|
||||
|
||||
| 存储 | 表 / 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 驱动元数据 | `eb_driver`(`driver_type = 'sms'`) | 安装插件时 `installDriverRecord()` 写入;含 `namespace`、`owner_plugin_id`、`status` |
|
||||
| 密钥与签名 | `eb_driver.config_fields` JSON | 每项定义 + 管理员保存的 `value` |
|
||||
| 一号通账号 | `eb_system_config` | 仅 `yihaotong`:`yihaotong_appid`、`yihaotong_appsecret`;`SmsDriverServices::getEnabledDriver()` 运行时回填 |
|
||||
| 通知模板 ID | 通知模块模板表 `extra` JSON | `sms_id`、`sms_driver_key`(保存通知时由 `NoticeConfigServices` 写入当前启用驱动) |
|
||||
|
||||
Dao 模型 `app/model/driver/sms/SmsDriver.php` 仍指向表名 `driver`,全局 scope 过滤 `driver_type=sms` 且 `owner_plugin_id <> ''`。
|
||||
|
||||
**互斥:** `SmsDriverServices::updateStatus()` 启用一条时会 `disableAllExcept($id)`,全局同时只有一个 `status=1` 的短信驱动。
|
||||
|
||||
## 8. 从安装到冒烟(逐步)
|
||||
|
||||
### 8.1 静态检查(不写库)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/sms/YourSms/Driver.php
|
||||
php think plugin:manifest-check
|
||||
php think arch:check # 只看本次新增违规
|
||||
```
|
||||
|
||||
### 8.2 安装与启用
|
||||
|
||||
1. 后台 **插件管理** 安装,或(管理员 token):
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://192.168.1.169/adminapi/plugin/install' \
|
||||
-H 'Authori-zation: Bearer <token>' \
|
||||
-d 'plugin_key=driver:sms:your_sms'
|
||||
curl -X POST 'http://192.168.1.169/adminapi/plugin/enable' \
|
||||
-H 'Authori-zation: Bearer <token>' \
|
||||
-d 'plugin_key=driver:sms:your_sms'
|
||||
```
|
||||
|
||||
2. 后台 **设置 → 短信驱动**(`/adminapi/sms_driver/*`):保存 `config_fields`,**启用**目标驱动(会自动关闭其他短信驱动)。
|
||||
|
||||
3. **通知设置**:对需要发短信的通知,在短信渠道填写服务商 **模板编号**(`sms_id`),模板内容占位符 `{code}` 等与服务商模板一致;保存后 extra 会带当前 `sms_driver_key`。
|
||||
|
||||
### 8.3 注册中心确认
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; (new think\App(getcwd()."/"))->initialize();
|
||||
foreach (crmeb\services\driver\DriverRegistry::scan("sms") as $k => $d)
|
||||
echo $k, " status=", $d["status"], " class=", crmeb\services\driver\DriverRegistry::getDriverClass("sms", $k), PHP_EOL;'
|
||||
```
|
||||
|
||||
期望:`your_sms` → `driver\sms\YourSms\Driver`,且对应插件已启用。
|
||||
|
||||
### 8.4 冒烟
|
||||
|
||||
| 场景 | 操作 | 期望 |
|
||||
| --- | --- | --- |
|
||||
| 验证码 | H5 登录页发验证码(`LoginServices::verify` → `pushSms`) | 手机收到短信;失败时接口有可读错误 |
|
||||
| 业务通知 | 触发一条已配置短信渠道的通知(如发货) | `SmsChannel::send` 成功;日志 `[短信诊断]` 无网关拒绝 |
|
||||
| 切换驱动 | 启用另一短信驱动后再发 | 旧 `sms_driver_key` 模板应被就绪检查拦截,需重配 `sms_id` |
|
||||
|
||||
本地联调账号见工作区规则:H5 `http://localhost:5173/`,接口 `http://192.168.1.169`。
|
||||
|
||||
## 9. 参考实现对照表
|
||||
|
||||
| 目录 | driver_key | 何时照抄 |
|
||||
| --- | --- | --- |
|
||||
| `Aliyun/` | `aliyun` | 宿主 SDK + 命名模板参数;注意应改为 `setError` 而非抛异常 |
|
||||
| `Tencent/` | `tencent` | 自签 TC3 + **位置参数** + E.164 手机号 |
|
||||
| `Yihaotong/` | `yihaotong` | 一号通 token;配置在 `sys_config`,勿作第三方模板 |
|
||||
| `Chuanglan/` | `chuanglan` | 创蓝 HTTP 示例 |
|
||||
| `yunxin/` | `yunxin` | **反例**:目录小写,仅作 Installer 示例 |
|
||||
|
||||
## 10. 打包交付
|
||||
|
||||
只打包插件根目录,zip 根即 `plugin.json` 所在目录,输出到仓库根 `.plugin-packages/`(不入库):
|
||||
|
||||
```bash
|
||||
cd backend/driver/sms/YourSms
|
||||
zip -r "../../../../.plugin-packages/某某短信-your_sms-1.0.0.zip" . \
|
||||
-x "*.DS_Store" -x "__MACOSX/*"
|
||||
```
|
||||
|
||||
文件名:`{中文名}-{key}-{version}.zip`。首版阶段 `version` 保持 `1.0.0`。交付说明中列出:插件包路径、需管理员重配的通知模板、以及第 2 节一号通后台能力范围。
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
**静态(必须全部通过):**
|
||||
|
||||
- [ ] `php -l driver/sms/{Dir}/Driver.php` 通过
|
||||
- [ ] `php think plugin:manifest-check` 通过
|
||||
- [ ] 目录名等于 `Str::studly(driver_key)`;`path` 与磁盘一致;`key` 与 `driver_key` 一致
|
||||
- [ ] `initialize()` 读取的键与 `config_fields[].field` 一一对应
|
||||
- [ ] `send(string $phone, string $templateId, array $data = [])` 存在;失败路径统一 `return $this->setError(...)`,不抛未捕获异常
|
||||
- [ ] 驱动内无 `use app\`、`use modules\`、无写业务表
|
||||
- [ ] 清单与代码中无真实密钥;密钥字段 `type` 为 `password`
|
||||
- [ ] 未修改 `backend/app/`、`backend/crmeb/`、核心前端、安装 SQL
|
||||
|
||||
**开发库冒烟(有沙箱号码时):**
|
||||
|
||||
- [ ] 安装并启用插件后 `DriverRegistry::scan('sms')` 含 `your_sms`
|
||||
- [ ] 后台保存配置、启用驱动后 `getEnabledDriver()` 返回你的 `driver_key`
|
||||
- [ ] 通知模板填写正确 `sms_id` 后,验证码或通知短信实发成功
|
||||
- [ ] 故意错误密钥 / 错误模板 ID → 返回明确失败,日志无完整手机号与密钥
|
||||
|
||||
## 12. 常见失败现象对照
|
||||
|
||||
| 现象 | 最可能原因 | 排查 |
|
||||
| --- | --- | --- |
|
||||
| 后台插件列表没有驱动 | 只拷目录未安装;或插件未 enable | 走 `plugin/install` + `enable`;查 `eb_module_install_log` |
|
||||
| `Driver [xxx] not supported.` | 目录名与 `Str::studly(driver_key)` 不一致 | 第 4 节命令核对 |
|
||||
| `未配置启用的短信驱动` | 无 `status=1` 或插件未 active | `sms_driver/enabled`;`owner_plugin_id` |
|
||||
| `短信模板未就绪或不属于当前驱动` | `sms_id` 空或 `sms_driver_key` 与当前驱动不符 | 通知设置重填模板编号;切换驱动后必重配 |
|
||||
| 模板变量对但网关报参数错 | 命名/位置参数与服务商不一致 | 对照第 6.3 节;腾讯云看变量顺序 |
|
||||
| 配置保存了但发送仍报未配置 | `config_fields` 无 `value` 或 field 名与 `initialize` 不一致 | 查库 JSON;对照 Aliyun 字段命名 |
|
||||
| 一号通账号显示只读无法改 | 正常;应走系统配置/一号通登录页 | 非 yihaotong 驱动不应依赖该路径 |
|
||||
| Linux 装完类找不到 | 目录大小写错误 | 勿模仿 `yunxin/` 小写目录 |
|
||||
|
||||
---
|
||||
|
||||
**一句话:** 短信驱动只需把 `send()` 对接网关;真正容易踩坑的是 **studly 目录**、**互斥启用**、**通知模板 `sms_id` + `sms_driver_key`**,以及 **腾讯云位置参数** 与 **fail 必须走 `setError`**。
|
||||
@ -0,0 +1,378 @@
|
||||
# 驱动分册 · 上传存储(upload)
|
||||
|
||||
核对日期:2026-11-06。先读 [00-总览](00-总览-类型路由与边界.md) 第 3 节(驱动通用机制)。本篇按「可照着新增一个驱动」补齐文件清单、契约与验收。
|
||||
|
||||
## 1. 何时用 / 何时不用
|
||||
|
||||
**适合做成 upload 驱动:**
|
||||
|
||||
- 商品图、用户头像、海报/二维码、导入文件等需要 **统一上传 / 删除 / 生成访问 URL**,换 OSS/COS/MinIO 等对象存储。
|
||||
- 业务入口已统一:`UploadService::init()`、`getOssInit($url)`,素材库写入由 `SystemAttachmentServices` 完成,驱动只返回 `getUploadInfo()` 结构。
|
||||
|
||||
**不要做成 upload 驱动:**
|
||||
|
||||
- 改附件权限、分类、引用计数 → `app/services/system/attachment/`,不是驱动。
|
||||
- 同一站点 **同时** 用 OSS 传图、本地传用户文件 → 存储驱动 **全局互斥**,仅一个 `status=1`;多源分流要改产品/核心,先问用户。
|
||||
- 只在单一模块用一次的文件工具 → 模块内处理,不必新开驱动。
|
||||
|
||||
## 2. 开工前必须问用户的核心写死点
|
||||
|
||||
| 写死点 | 为何要问 | 代码依据 |
|
||||
| --- | --- | --- |
|
||||
| 公有读还是私有桶 + 签名 URL | 现役云驱动默认公有读 URL;私有桶要在驱动内做签名或走 CDN | 各云 `Driver.php` |
|
||||
| 缩略图 / 水印 | 云存储常只做 URL 参数(Cos)或跳过;Local 用 GD 落盘 | `BaseUpload::getUploadInfo()`、`thumb()` |
|
||||
| 是否要实现 `stream` | 海报/远程抓取会调 `stream()`;未声明 `capabilities` 含 `stream` 也可实现,但要与用户预期一致 | `UploadService`、Cos `stream` |
|
||||
| Bucket 管理 API | `bucket_create` 等 capability 影响后台「空间同步」按钮是否可用 | `plugin.json` capabilities |
|
||||
| 字段命名习惯 | 历史驱动用 `tengxun_accessKey` 等;`normalizeDriverConfig()` 会映射到 `accessKey`/`secretKey` | `UploadDriverServices::normalizeDriverConfig()` |
|
||||
| SDK 放哪 | 大 SDK 放 `extend/`,禁止改根 `composer.json` | 总览 3.7、`driver/upload/Cos/extend/` |
|
||||
|
||||
## 3. 三层结构(先建立心智模型)
|
||||
|
||||
| 层 | 位置 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| 清单 | `driver/upload/{Dir}/plugin.json` | 表单字段、空间字段、域名模板、能力声明 |
|
||||
| 字典 | `eb_driver`(`driver_type=upload`) | 运行时真相;`DriverRegistry::scan()` **只读表** |
|
||||
| 空间 | `eb_upload_storage` | 每个 bucket 的 `name/region/domain/cdn`,启用云驱动前必须有 `status=1` 的空间 |
|
||||
| 类解析 | `crmeb\services\upload\Upload` | `Str::studly($driver_key)` → `driver\upload\{Dir}\Driver` |
|
||||
|
||||
**只拷贝目录不安装插件** → 字典无行、Registry 扫不到 → 后台列表没有新驱动;点「同步存储驱动」**不会新增行**(只更新已有行的元数据)。
|
||||
|
||||
## 4. 运行时链路
|
||||
|
||||
```text
|
||||
Controller / Job
|
||||
→ UploadService::init($typeOrKey|null)
|
||||
→ UploadDriverServices::getEnabledDriverKey() / resolveDriverKey()
|
||||
→ loadDriverConfig():eb_driver.config_fields + eb_upload_storage 当前空间
|
||||
→ normalizeDriverConfig()
|
||||
→ new Upload($driverKey, $config)
|
||||
→ Driver::to($path)->validate()->move('file')
|
||||
→ getUploadInfo() → SystemAttachmentServices 写 eb_system_attachment
|
||||
```
|
||||
|
||||
关键文件:
|
||||
|
||||
- 门面:`backend/crmeb/services/upload/Upload.php`
|
||||
- 基类:`backend/crmeb/services/upload/BaseUpload.php`
|
||||
- 编排:`backend/app/services/driver/upload/UploadService.php`
|
||||
- 管理:`backend/app/services/driver/upload/UploadDriverServices.php`
|
||||
- 空间:`backend/app/services/driver/upload/UploadStorageServices.php`
|
||||
- 后台:`backend/app/adminapi/route/driver.php` → `upload_driver/*`、`upload_storage/*`
|
||||
- 上传接口:`POST /adminapi/file/upload/[:upload_type]`(`0` = 当前启用驱动)
|
||||
|
||||
## 5. 目录与命名
|
||||
|
||||
```text
|
||||
backend/driver/upload/{Dir}/
|
||||
├── plugin.json
|
||||
├── Driver.php # extends BaseUpload
|
||||
└── extend/ # 可选,如 extend/cos/Client.php
|
||||
```
|
||||
|
||||
| driver_key | {Dir} | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `local` | `Local` | 内置默认 |
|
||||
| `oss` | `Oss` | 阿里云 |
|
||||
| `cos` | `Cos` | 腾讯云,含 extend |
|
||||
| `minio` | `Minio` | 示例:key 与目录 studly 一致 |
|
||||
|
||||
```bash
|
||||
cd backend && php -r 'require "vendor/autoload.php"; echo \think\helper\Str::studly("s3_minio"), PHP_EOL;'
|
||||
```
|
||||
|
||||
## 6. plugin.json 完整示例与字段表
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "your_oss",
|
||||
"name": "某某对象存储",
|
||||
"description": "S3 兼容 / 私有云对象存储",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "driver",
|
||||
"path": "driver/upload/YourOss",
|
||||
"default_install": false,
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" },
|
||||
"driver": {
|
||||
"name": "某某对象存储",
|
||||
"driver_key": "your_oss",
|
||||
"class_name": "Driver",
|
||||
"icon": "icon-storage",
|
||||
"sort": 60,
|
||||
"domain_template": "https://{name}.{region}.example.com",
|
||||
"capabilities": ["upload", "delete", "stream", "thumb", "water"],
|
||||
"config_fields": [
|
||||
{
|
||||
"field": "your_oss_accessKey",
|
||||
"label": "AccessKey",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "密钥配置"
|
||||
},
|
||||
{
|
||||
"field": "your_oss_secretKey",
|
||||
"label": "SecretKey",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"group": "密钥配置"
|
||||
},
|
||||
{
|
||||
"field": "your_oss_endpoint",
|
||||
"label": "Endpoint",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"group": "服务配置",
|
||||
"tip": "不含 bucket 名,如 https://minio.example.com"
|
||||
}
|
||||
],
|
||||
"bucket_fields": [
|
||||
{ "field": "name", "label": "空间名称", "type": "text", "required": true },
|
||||
{ "field": "region", "label": "区域", "type": "text", "required": false }
|
||||
],
|
||||
"thumb_watermark_config": {},
|
||||
"help_doc": {
|
||||
"title": "接入说明",
|
||||
"steps": [
|
||||
"创建 Bucket 并设置访问权限",
|
||||
"填写密钥与 Endpoint",
|
||||
"在「空间配置」添加 Bucket 并填写访问域名",
|
||||
"保存配置后启用驱动"
|
||||
],
|
||||
"link": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**upload 专属 `driver` 字段:**
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `domain_template` | 后台添加空间时自动生成 `domain` 的模板;占位符 `{name}` `{region}` `{appid}` |
|
||||
| `bucket_fields` | 「空间配置」动态表单;与 `eb_upload_storage` 列对应 |
|
||||
| `capabilities` | 如 `upload`、`delete`、`stream`、`thumb`、`water`、`bucket_list`、`temp_keys` 等;影响后台能力展示与部分同步功能 |
|
||||
| `thumb_watermark_config` | 安装时可带默认;运行时在 `eb_driver.thumb_watermark_config` 保存缩略图/水印开关 |
|
||||
| `region_map` | 可选,Cos 等用于区域中文名 |
|
||||
|
||||
`config_fields` 保存后写入 `eb_driver.config_fields[].value`。保存接口白名单校验,未声明字段会被拒。
|
||||
|
||||
## 7. Driver.php 契约
|
||||
|
||||
### 7.1 基类抽象方法(必须实现)
|
||||
|
||||
```php
|
||||
// crmeb\services\upload\BaseUpload
|
||||
abstract public function upload(string $filePath, string $key);
|
||||
abstract public function delete(string $key);
|
||||
abstract public function getFilePath(string $key);
|
||||
abstract public function getFileInfo();
|
||||
```
|
||||
|
||||
### 7.2 业务实际上传链(必须对齐现役驱动)
|
||||
|
||||
管理端与用户端上传走 **`move()`**,不是直接调 `upload()`:
|
||||
|
||||
```php
|
||||
public function to(string $path): self; // 设置 $this->path(如 image/2026/11)
|
||||
public function validate(?array $rules = null): self;
|
||||
public function move(string $file = 'file', $realName = false); // 读 $_FILES,校验,上传
|
||||
```
|
||||
|
||||
可选但常见:
|
||||
|
||||
```php
|
||||
public function stream($fileContent, ?string $key = null); // 流式写入
|
||||
protected function initialize(array $config); // 读密钥 + uploadUrl/storageName/storageRegion/cdn
|
||||
```
|
||||
|
||||
`initialize()` 内应调用 `parent::initialize($config)`,并赋值:
|
||||
|
||||
- 来自 `config_fields`:`accessKey`、`secretKey`、`endpoint` 等(经 `normalizeDriverConfig` 后可能有短名)
|
||||
- 来自空间表:`uploadUrl`(访问域名前缀)、`storageName`(bucket)、`storageRegion`、`cdn`
|
||||
|
||||
### 7.3 成功上传必须赋值的 `fileInfo`
|
||||
|
||||
`BaseUpload::getUploadInfo()` 依赖:
|
||||
|
||||
| 属性 | 含义 |
|
||||
| --- | --- |
|
||||
| `fileName` | 对象键 / 存储路径(入库 `name`) |
|
||||
| `realName` | 用户原始文件名 |
|
||||
| `filePath` | 可访问完整 URL(入库 `dir` / `att_dir`) |
|
||||
| `filePathMid` | 中图;无则回落 `filePath` |
|
||||
| `filePathWater` | 水印图;开启水印时可能替换对外 `filePath` |
|
||||
|
||||
少赋 `realName` → 素材库文件名列异常。云存储无图片服务时,`thumb()` 可返回原 URL。
|
||||
|
||||
### 7.4 最小骨架(签名真实)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace driver\upload\YourOss;
|
||||
|
||||
use crmeb\services\upload\BaseUpload;
|
||||
|
||||
class Driver extends BaseUpload
|
||||
{
|
||||
protected function initialize(array $config)
|
||||
{
|
||||
parent::initialize($config);
|
||||
// $this->accessKey = $config['accessKey'] ?? '';
|
||||
}
|
||||
|
||||
public function to(string $path) { $this->path = $path; return $this; }
|
||||
|
||||
public function validate(?array $rules = null)
|
||||
{
|
||||
$this->validate = $rules ?: [
|
||||
'fileExt' => config('upload.fileExt'),
|
||||
'fileMime' => config('upload.fileMime'),
|
||||
'filesize' => config('upload.filesize'),
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function move(string $file = 'file', $realName = false)
|
||||
{
|
||||
// 参照 Local/Cos:取 request->file(),校验,put 对象,填充 $this->fileInfo
|
||||
return $this->fileInfo;
|
||||
}
|
||||
|
||||
public function upload(string $filePath, string $key) { /* ... */ }
|
||||
public function delete(string $key) { /* ... */ }
|
||||
public function getFilePath(string $key): string { /* ... */ }
|
||||
public function getFileInfo() { return $this->fileInfo; }
|
||||
}
|
||||
```
|
||||
|
||||
完整实现请对照 `backend/driver/upload/Cos/Driver.php` 或 `Local/Driver.php`。
|
||||
|
||||
## 8. 配置与表
|
||||
|
||||
| 表 | 内容 |
|
||||
| --- | --- |
|
||||
| `eb_driver` | `driver_type=upload`;`config_fields`、`thumb_watermark_config`、`status` |
|
||||
| `eb_upload_storage` | `driver_key`、`name`、`region`、`domain`、`cdn`、`status`、`is_delete` |
|
||||
| `eb_system_attachment` | 上传结果;`image_type` 由 `getTypeDriverKeyMap()` 按 **驱动 id 升序** 映射,**非稳定枚举** |
|
||||
|
||||
`UploadDriverServices::saveDriverConfig()` / `validateDriverConfig()`:只允许 JSON 里声明过的 `field`;敏感字段未改时可传占位符 `********` 保留原值。
|
||||
|
||||
启用云驱动时 `UploadDriverServices::update()`:
|
||||
|
||||
1. `DriverRegistry::get('upload', $driver_key)` 必须存在(插件已装且 enable)。
|
||||
2. 非 `local` 必须有 **使用中** 的空间:`upload_storage` 且 `status=1`、`is_delete=0`。
|
||||
3. 启用成功会 **`disableAllExcept($id)`**,全局仅一个存储驱动开启。
|
||||
|
||||
## 9. 从安装到冒烟(严格顺序)
|
||||
|
||||
### 9.1 开发与静态检查
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php -l driver/upload/YourOss/Driver.php
|
||||
php think plugin:manifest-check
|
||||
```
|
||||
|
||||
### 9.2 安装插件
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://192.168.1.169/adminapi/plugin/install' \
|
||||
-H 'Authori-zation: Bearer <token>' -d 'plugin_key=driver:upload:your_oss'
|
||||
curl -X POST 'http://192.168.1.169/adminapi/plugin/enable' \
|
||||
-H 'Authori-zation: Bearer <token>' -d 'plugin_key=driver:upload:your_oss'
|
||||
```
|
||||
|
||||
查库:
|
||||
|
||||
```sql
|
||||
SELECT driver_key, namespace, owner_plugin_id, status
|
||||
FROM eb_driver WHERE driver_type = 'upload' AND driver_key = 'your_oss';
|
||||
```
|
||||
|
||||
### 9.3 配置 → 空间 → 启用(顺序不可反)
|
||||
|
||||
1. **保存驱动配置**(密钥、Endpoint)→ `POST upload_driver/save_config/:id`
|
||||
2. **空间配置** → 添加 bucket,`domain` 填浏览器可访问前缀(错误则 `att_dir` 永久 404)
|
||||
3. **启用驱动** → `PUT upload_driver/set_status/:id`;若报「未有正在使用的存储空间」,先开空间再启用
|
||||
|
||||
### 9.4 上传冒烟
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://192.168.1.169/adminapi/file/upload/0' \
|
||||
-H 'Authori-zation: Bearer <token>' \
|
||||
-F 'file=@./test.png' -F 'pid=0'
|
||||
```
|
||||
|
||||
期望:`data.src` 可打开;库中 `att_dir` 为完整 URL(云存储)或带站点路径(local)。
|
||||
|
||||
### 9.5 删除与按 URL 识别
|
||||
|
||||
- 删素材:走 attachment 服务 → 驱动 `delete()`
|
||||
- 海报/迁移:`UploadService::getOssInit($url)` 按 `domain`/`cdn` 匹配空间;多匹配抛「文件地址匹配多个存储空间」
|
||||
|
||||
## 10. 参考实现对照表
|
||||
|
||||
| 目录 | driver_key | 建议 |
|
||||
| --- | --- | --- |
|
||||
| `Local/` | `local` | 最小 `move()`、GD 缩略图与水印 |
|
||||
| `Oss/` | `oss` | 阿里云 API 字段命名 |
|
||||
| `Cos/` | `cos` | 云存储 + `extend/cos` + URL 缩略图 |
|
||||
| `Qiniu/` | `qiniu` | 七牛 |
|
||||
| `Obs/` | `obs` | 华为,含 extend |
|
||||
| `Jdoss/` | `jdoss` | 京东云 |
|
||||
| `Tyoss/` | `tyoss` | 天翼云 |
|
||||
|
||||
**新增 S3 兼容:** 结构照 `Cos/`,HTTP 客户端照 `Cos/extend/cos/Client.php`;字段与 `domain_template` 按厂商改。
|
||||
|
||||
## 11. 打包交付
|
||||
|
||||
```bash
|
||||
cd backend/driver/upload/YourOss
|
||||
zip -r "../../../../.plugin-packages/某某存储-your_oss-1.0.0.zip" . \
|
||||
-x "*.DS_Store" -x "__MACOSX/*"
|
||||
```
|
||||
|
||||
解压后根目录即 `plugin.json`。勿打整个 `backend/` 或带 `.env`。
|
||||
|
||||
## 12. 验收清单
|
||||
|
||||
**静态(必须全部通过):**
|
||||
|
||||
- [ ] `php -l`、`php think plugin:manifest-check` 通过
|
||||
- [ ] 目录名 = `Str::studly(driver_key)`;四个名字对齐
|
||||
- [ ] 四个抽象方法 + `move()`/`to()`/`validate()` 已实现且签名与基类/现役驱动一致
|
||||
- [ ] `getUploadInfo()` 所需 `fileInfo` 字段在上传成功路径均已赋值
|
||||
- [ ] 驱动不写 `eb_system_attachment`;无 `use app\` / `use modules\`
|
||||
- [ ] 大 SDK 仅在 `extend/`;未改根 `composer.json`
|
||||
- [ ] `capabilities` 与真实能力一致(声明 `stream` 则 `stream()` 可用)
|
||||
|
||||
**开发库冒烟:**
|
||||
|
||||
- [ ] 安装启用后 Registry 可见;`owner_plugin_id` 非空
|
||||
- [ ] 配置保存、空间开启、驱动启用三步完成
|
||||
- [ ] `file/upload/0` 成功;`att_dir` 浏览器可访问
|
||||
- [ ] 删除素材后对象存储侧对象消失(或符合私有桶策略)
|
||||
- [ ] 切换回 `local` 前若卸载云驱动,先切换驱动避免上传断链
|
||||
|
||||
## 13. 常见失败现象对照
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 后台无新驱动 | 未安装插件;Registry 不扫目录 | plugin install + enable |
|
||||
| 点「同步」仍无新行 | `syncDrivers` 故意不 INSERT | 必须走插件安装 |
|
||||
| `请先启用对应的驱动插件` | 插件 disable 或 Registry 无记录 | 插件管理启用 |
|
||||
| `未有正在使用的存储空间` | 未添空间或未 `status=1` | 空间配置 |
|
||||
| `Driver [x] not supported.` | studly 与目录不一致 | 改目录或 key |
|
||||
| 上传成功 URL 404 | `domain` / CDN 填错 | 改 `eb_upload_storage.domain` |
|
||||
| `不支持的驱动配置字段` | POST 了 JSON 未声明字段 | 改 plugin.json 重装或手工改库定义 |
|
||||
| 改 JSON 后表单无新字段 | `config_fields` 是安装快照 | 重装插件或 sync(upload 仅更新已有行) |
|
||||
| 海报/二维码域名重复拼接 | `image_type==1` 假定 local;驱动 id 顺序变过 | 保持 local 最小 id;别用 image_type 做迁移键 |
|
||||
| `文件地址匹配多个存储空间` | 两空间 domain 重叠 | 区分域名或路径前缀 |
|
||||
| 卸载插件后上传崩 | 字典删了但仍是启用态 | 卸载前先切 local;清空间 |
|
||||
| 敏感配置明文展示 | 字段名未命中脱敏规则 | 用 `password` 或 `secretKey` 命名 |
|
||||
|
||||
---
|
||||
|
||||
**一句话:** 上传驱动 = **`plugin.json` 定表单 + 安装写 `eb_driver` + 空间定域名 + `move()` 填好 `fileInfo`**;四者任一错位,表现都是上传失败,但报错各不相同。
|
||||
@ -0,0 +1,292 @@
|
||||
# 业务模块分册 · 用户渠道(channel)
|
||||
|
||||
核对日期:2026-11-06(按 `backend/modules/channel/` 现役代码核对)。代码与本文冲突时以代码为准。
|
||||
|
||||
**先读**:[00-总览-类型路由与边界](00-总览-类型路由与边界.md) 第 4 节;领域判定:[domain-selection.md](../domain-selection.md);代理与事件:[registration-and-contracts.md](../registration-and-contracts.md)。
|
||||
|
||||
强制红线:[插件开发规范](../../../../rules/插件开发规范.md)、[架构规范](../../../../rules/架构规范.md)(app 不得 use channel)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 领域判定:口诀与反例
|
||||
|
||||
### 1.1 口诀
|
||||
|
||||
**用户从哪个端来、如何识别身份、该端专属 SDK/API/订阅消息** → `backend/modules/channel/{client}/`。
|
||||
|
||||
渠道模块封装:登录(code2Session、OAuth)、openid/unionid、小程序码、URL Scheme、端专属支付凭证、端专属通知通道。
|
||||
|
||||
### 1.2 应归入 channel
|
||||
|
||||
| 能力 | 现役 |
|
||||
| --- | --- |
|
||||
| 微信小程序登录与 openid | `channel/wechat_mini`(`Form-type: routine`) |
|
||||
| 微信公众号 | `channel/wechat_mp` |
|
||||
| APP 端 | `channel/app` |
|
||||
| H5 浏览器 | `channel/h5` |
|
||||
|
||||
### 1.3 反例
|
||||
|
||||
| 误判 | 正确落位 |
|
||||
| --- | --- |
|
||||
| 微信支付协议、退款 | `driver/pay/*` |
|
||||
| 短信验证码 | `driver/sms/*` |
|
||||
| 全端共用的用户资料 CRUD | `app/services/user/*`(通过事件调渠道) |
|
||||
| 分销绑粉 | `distribution` |
|
||||
|
||||
### 1.4 与 app 的分工
|
||||
|
||||
- **app**:订单、用户中台、支付编排;通过 `event('ChannelServiceProxy')`、`event('MiniProgramApiCall')` 等请求渠道能力。
|
||||
- **channel**:实现监听,调用 `sdk/` 或微信 API;**渠道之间禁止互引**。
|
||||
- **禁止** `app/` 内 `use modules\channel\*`(架构铁律)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现役模块对照
|
||||
|
||||
| 目录 | plugin key | type | default_install | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `channel/wechat_mini` | `channel/wechat_mini` | `channel` | true | `App::detect()`、`getOpenid()` |
|
||||
| `channel/wechat_mp` | `channel/wechat_mp` | `channel` | 视清单 | 公众号 OAuth |
|
||||
| `channel/app` | `channel/app` | `channel` | 视清单 | APP Form-type |
|
||||
| `channel/h5` | `channel/h5` | `channel` | 视清单 | H5 |
|
||||
|
||||
key 现役使用 **`channel/{name}` 带斜杠** 形式,与 product 的 `product_*` 不同;新渠道建议延续以免与菜单/权限历史冲突。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐参考
|
||||
|
||||
| 场景 | 参考 |
|
||||
| --- | --- |
|
||||
| 完整 SDK + 代理 + 多事件 | `channel/wechat_mini` |
|
||||
| 公众号 OAuth / 模板消息 | `channel/wechat_mp` |
|
||||
| 最小端 | `channel/h5` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录树
|
||||
|
||||
```text
|
||||
backend/modules/channel/{client}/
|
||||
├── plugin.json # type=channel, key 常为 channel/{client}
|
||||
├── App.php # detect/getFormType/getOpenid;register 常为空
|
||||
├── Installer.php # 配置 seed、通知模板等(按需)
|
||||
├── event.php # MiniProgramApiCall、ChannelServiceProxy 等
|
||||
├── listener/
|
||||
├── sdk/ # 封装第三方 API
|
||||
├── notify/ # NotifyChannelInterface 实现(订阅消息等)
|
||||
├── controller/user|admin/ # 登录、绑定、回调(按需)
|
||||
├── route/
|
||||
├── service/ dao/ model/
|
||||
├── jobs/ # 异步通知
|
||||
└── validate/
|
||||
|
||||
frontend/admin/src/pages/plugin/channel_{client}/ # 或 key 含斜杠时的实际目录名
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. plugin.json
|
||||
|
||||
微信小程序真源片段:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "channel/wechat_mini",
|
||||
"name": "微信小程序",
|
||||
"type": "channel",
|
||||
"path": "modules/channel/wechat_mini",
|
||||
"default_install": true,
|
||||
"diy": {
|
||||
"components": [
|
||||
{ "name": "liveBroadcast", "title": "小程序直播" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支付驱动可通过 `required_credentials` 声明需要 openid;核心 `PayCredentialService` 会按端查找 channel 模块的 `App::getOpenid($uid)`(见 `wechat_mini/App.php` 注释)。
|
||||
|
||||
---
|
||||
|
||||
## 6. App.php 契约
|
||||
|
||||
### 6.1 端识别(wechat_mini 模式)
|
||||
|
||||
| 方法 | 作用 |
|
||||
| --- | --- |
|
||||
| `detect(): bool` | 当前请求是否属于本端(如 header `Form-type` === `routine`) |
|
||||
| `getFormType(): string` | 返回约定 header 值 |
|
||||
| `getName(): string` | 展示名 |
|
||||
| `getOpenid(int $uid): string` | 支付/授权所需 openid(委托 app UserWechatAuthServices) |
|
||||
|
||||
新端渠道应实现同构静态方法,供核心按约定反射/调用(对齐现役 wechat_mini 文档注释)。
|
||||
|
||||
### 6.2 register / boot
|
||||
|
||||
- `register()` 通常为空(无 Registry)。
|
||||
- `boot()` 仅 `loadEvent(event.php)`。
|
||||
|
||||
不得在 `AppService` 硬编码渠道类;端路由由 channel 模块 `route/user.php` 提供。
|
||||
|
||||
---
|
||||
|
||||
## 7. event.php 与监听(方法级职责)
|
||||
|
||||
### 7.1 wechat_mini 真源
|
||||
|
||||
```php
|
||||
return [
|
||||
'listen' => [
|
||||
'MiniProgramApiCall' => [ MiniProgramApiCallListener::class ],
|
||||
'MiniProgramCodeGenerate' => [ MiniProgramCodeGenerateListener::class ],
|
||||
'ChannelServiceProxy' => [ ChannelServiceProxyListener::class ],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
| 事件 | Listener 典型职责 |
|
||||
| --- | --- |
|
||||
| `MiniProgramApiCall` | 统一入口调 wx API;参数校验;错误转业务异常 |
|
||||
| `MiniProgramCodeGenerate` | 生成小程序码/海报参数 |
|
||||
| `ChannelServiceProxy` | 白名单响应 app 层查询(配置、能力开关) |
|
||||
|
||||
### 7.2 编写要求
|
||||
|
||||
- 请求/响应结构稳定、文档化(app 侧依赖)。
|
||||
- SDK 异常捕获:可降级时返回空/false,并打日志;不要泄露 secret 到日志。
|
||||
- 未配置 appId/secret 时明确错误信息(后台配置页引导)。
|
||||
|
||||
---
|
||||
|
||||
## 8. sdk/ 与 notify/
|
||||
|
||||
- **sdk/**:access_token、code2Session、订阅消息发送等;仅 channel 模块内部使用。
|
||||
- **notify/**:实现核心通知体系的渠道接口(若模块提供订阅消息通道),注册方式对齐 wechat_mini 的 `notify/` 现役实现。
|
||||
|
||||
驱动层支付/短信仍在 `driver/`,不要混进 channel sdk。
|
||||
|
||||
---
|
||||
|
||||
## 9. Installer
|
||||
|
||||
- 渠道配置常存自有表或约定配置表(**禁止**乱写系统表 DDL)。
|
||||
- seed 通知模板、菜单(渠道配置页)。
|
||||
- 卸载清理 token 缓存键(若有 Redis key,在 `cleanData` 按前缀删)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 路由与 Controller
|
||||
|
||||
- 用户端:登录、绑定手机号、授权回调 URL。
|
||||
- 管理端:渠道参数、版本发布、订阅模板同步(视端能力)。
|
||||
- 回调 URL 需与微信/第三方控制台一致;验签在 channel 模块完成后再调 app 用户服务。
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端契约
|
||||
|
||||
- 管理端:plugin 页配置 appId、secret(字段走后台表单,不入库 plugin.json)。
|
||||
- uni-app:**不改**全局登录页硬编码;通过已有登录 API + `Form-type` header 区分端。
|
||||
- 新端若需独立登录页:User API + 文档说明 header 约定。
|
||||
|
||||
---
|
||||
|
||||
## 12. 逐步落地(新端渠道)
|
||||
|
||||
1. 确定 **Form-type**(或等价检测规则)与 **plugin key**(建议 `channel/{client}`)。
|
||||
2. 复制 **wechat_mini** 或 **h5** 骨架。
|
||||
3. 实现 `App::detect`、`getFormType`、支付所需 `getOpenid`(若适用)。
|
||||
4. 实现 `sdk/` 最小登录链路。
|
||||
5. `event.php`:至少 `ChannelServiceProxy` 白名单。
|
||||
6. User 路由:登录/刷新 session。
|
||||
7. Installer:配置项 seed + 管理菜单。
|
||||
8. 与支付驱动联调:`required_credentials` + openid 获取全链路。
|
||||
9. 订阅/模板消息(可选):`notify/` + 业务事件触发。
|
||||
10. 禁用/卸载验收。
|
||||
11. 打包。
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收清单
|
||||
|
||||
### 13.1 静态
|
||||
|
||||
```bash
|
||||
cd backend && php think plugin:manifest-check && php think arch:check
|
||||
```
|
||||
|
||||
确认无 `app → modules\channel` 反向依赖新增。
|
||||
|
||||
### 13.2 功能
|
||||
|
||||
- [ ] 对应 `Form-type` 请求 `detect()` 为 true,其他端为 false
|
||||
- [ ] 登录成功:用户绑定 openid/unionid(表字段与 app 服务一致)
|
||||
- [ ] 支付:能取到 openid(小程序/公众号场景)
|
||||
- [ ] 代理事件:app 调用 ChannelServiceProxy 返回预期
|
||||
- [ ] SDK 失败:可理解错误,不 500 整站
|
||||
- [ ] 禁用渠道:登录入口不可用或降级,**不影响**其他端登录
|
||||
- [ ] 卸载:自有配置与菜单清理(隔离库)
|
||||
|
||||
### 13.3 安全
|
||||
|
||||
- [ ] secret 仅后台配置表/加密字段,不在代码与 git
|
||||
- [ ] 回调验签、防重放(按平台要求)
|
||||
|
||||
---
|
||||
|
||||
## 14. 打包
|
||||
|
||||
打包 `modules/channel/{client}/` + 管理端 plugin 资源 → `.plugin-packages/`。
|
||||
|
||||
---
|
||||
|
||||
## 15. 禁止事项
|
||||
|
||||
- 禁止 channel 模块互相 `use`。
|
||||
- 禁止 channel 直接改订单/支付状态(应回调 app 服务或触发已有事件)。
|
||||
- 禁止在 app 控制器写死某端 SDK 调用(应 event 到 channel)。
|
||||
- 禁止把支付 Driver 放进 channel 目录。
|
||||
|
||||
---
|
||||
|
||||
## 16. 排错
|
||||
|
||||
| 现象 | 查 |
|
||||
| --- | --- |
|
||||
| 登录 404 | 插件启用;user 路由;Form-type |
|
||||
| openid 空 | UserWechatAuth 绑定;getOpenid 的 type 参数 |
|
||||
| 支付缺 openid | PayCredentialService;驱动 required_credentials |
|
||||
| 代理无响应 | ChannelServiceProxy 是否注册;方法是否在白名单 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 架构关系图
|
||||
|
||||
```text
|
||||
uni-app (Form-type: routine)
|
||||
→ api 登录
|
||||
→ app UserServices
|
||||
→ event('MiniProgramApiCall', ...)
|
||||
→ channel/wechat_mini/listener → sdk → 微信 API
|
||||
|
||||
PayServices 需要 openid
|
||||
→ PayCredentialService
|
||||
→ channel App::getOpenid(uid)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Form-type 与端对照(联调)
|
||||
|
||||
联调移动端 H5(`http://localhost:5173/`)时,请求头 `Form-type` 必须与渠道模块约定一致,否则 `detect()` 为 false、openid 解析走错端。
|
||||
|
||||
| 渠道模块 | `getFormType()` / detect 比较值 | 典型场景 |
|
||||
| --- | --- | --- |
|
||||
| `wechat_mini` | `routine` | 微信小程序 |
|
||||
| `wechat_mp` | 见模块 `App.php` | 公众号内 H5 |
|
||||
| `app` | APP 壳 | uni-app 打包 App |
|
||||
| `h5` | 浏览器 | 纯 H5 |
|
||||
|
||||
新增端时在本表登记,并在 `PayCredentialService` 所需端的 `required_credentials` 与 `getOpenid` 实现保持一致。
|
||||
@ -0,0 +1,280 @@
|
||||
# 业务模块分册 · 分销(distribution)
|
||||
|
||||
核对日期:2026-11-06(按 `backend/modules/distribution/` 现役代码核对)。代码与本文冲突时以代码为准。
|
||||
|
||||
**先读**:[00-总览-类型路由与边界](00-总览-类型路由与边界.md) 第 4 节;领域判定:[domain-selection.md](../domain-selection.md)(扁平领域 `modules/distribution`);事件与代理:[registration-and-contracts.md](../registration-and-contracts.md)。
|
||||
|
||||
强制红线:[插件开发规范](../../../../rules/插件开发规范.md)、[安全红线](../../../../rules/安全红线.md)(佣金/提现权限)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 领域判定:口诀与反例
|
||||
|
||||
### 1.1 口诀
|
||||
|
||||
**分销关系、等级、事业部、佣金快照、结算与提现业务** → 单一扁平模块 `backend/modules/distribution/`(不是 `modules/distribution/xxx/` 多插件)。
|
||||
|
||||
与 marketing 区别:分销改变的是**订单/用户上的佣金分配与结算状态**,有强对账要求;不是单纯的「送一张券」。
|
||||
|
||||
### 1.2 应在本模块内扩展
|
||||
|
||||
- 绑粉、上下级关系变更
|
||||
- 订单支付后写佣金快照、分账比例
|
||||
- 佣金冻结/解冻、退款回滚
|
||||
- 提现申请、审核、打款触发(打款协议在 pay 驱动)
|
||||
- 用户/订单列表展示分销字段(Extend 事件)
|
||||
|
||||
### 1.3 反例
|
||||
|
||||
| 误判 | 正确落位 |
|
||||
| --- | --- |
|
||||
| 普通满减券活动 | `marketing` |
|
||||
| 微信商户转账 API | `driver/pay`(merchantPay 能力) |
|
||||
| 商品形态/库存 | `product` |
|
||||
| 秒杀活动价 | `trade` |
|
||||
|
||||
### 1.4 扩展方式
|
||||
|
||||
- **优先**在现有 `distribution` 模块内加 Service/Dao/Listener。
|
||||
- 若必须独立插件化能力,仍应保持**单一分销域**,通过事件对外;不要另建 `modules/marketing/xxx` 写佣金逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块身份
|
||||
|
||||
| 项 | 值(真源 plugin.json) |
|
||||
| --- | --- |
|
||||
| path | `modules/distribution` |
|
||||
| key | `distribution` |
|
||||
| type | `distribution`(非 `service`) |
|
||||
| 命名空间 | `modules\distribution\...` |
|
||||
|
||||
这是**大型扁平模块**参考范例(domain-selection:扁平领域参考 agent/distribution)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录树(现役)
|
||||
|
||||
```text
|
||||
backend/modules/distribution/
|
||||
├── plugin.json
|
||||
├── App.php # boot 加载 event;register 当前无 Registry
|
||||
├── Installer.php # 菜单、表、种子
|
||||
├── event.php # ★ 分销事件网
|
||||
├── listener/ # 支付、快照、Extend、Proxy、通知
|
||||
├── controller/admin|user/
|
||||
├── route/admin.php | user.php
|
||||
├── service/ dao/ model/
|
||||
├── jobs/ # 异步结算、注册后任务
|
||||
├── crontab/ # 解绑、结算类任务(若有)
|
||||
└── validate/
|
||||
|
||||
frontend/admin/src/pages/plugin/distribution/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "distribution",
|
||||
"name": "分销代理",
|
||||
"type": "distribution",
|
||||
"path": "modules/distribution",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
扩展子功能**不要**新建第二个 plugin.json;用版本迭代与 `Installer` migrate。
|
||||
|
||||
---
|
||||
|
||||
## 5. App.php
|
||||
|
||||
真源:`register()` 仅注释说明事件在 `event.php`;`boot()` 标准 `loadEvent`。
|
||||
|
||||
若新增定时任务(如自动解绑):
|
||||
|
||||
```php
|
||||
$app->make(CrontabTaskRegistry::class)->register('distributionUnbind', XxxTask::class);
|
||||
```
|
||||
|
||||
并在 Installer `seed()` 写 `system_crontab`,`cleanData()` 按 mark 删除。
|
||||
|
||||
---
|
||||
|
||||
## 6. event.php 契约(现役监听一览)
|
||||
|
||||
真源 `backend/modules/distribution/event.php`(节选说明):
|
||||
|
||||
| 事件 | Listener 职责 |
|
||||
| --- | --- |
|
||||
| `OrderPaySuccess` | 支付后佣金计算/入账 |
|
||||
| `OrderCreateAfter` | 下单快照(关系、比例) |
|
||||
| `OrderCenterDetailExtend` | 订单详情分销字段 |
|
||||
| `UserListExtend` / `UserDetailExtend` | 用户列表/详情扩展 |
|
||||
| `UserPromoterCancel` | 取消推广员 |
|
||||
| `OrderSpreadBrokerage` | Spread 佣金查询/计算 |
|
||||
| `OrderDivisionPercent` / `OrderDivisionInfoQuery` | 事业部/分账比例 |
|
||||
| `UserRegisterDistributionJob` | 注册后异步分销任务 |
|
||||
| `UserExtract` / `UserExtractApply` | 提现通知 |
|
||||
| `UserBrokerageChange` | 佣金变更通知 |
|
||||
| `BrokerageBindingStatusChange` | 绑粉状态变更 |
|
||||
| `DistributionUnbindCrontab` | 定时解绑逻辑入口 |
|
||||
| `DistributionServiceProxy` | **app 层白名单代理**,避免 use modules |
|
||||
|
||||
新增分销规则时:
|
||||
|
||||
1. 优先挂现有事件(如退款事件搜 `OrderRefund` 相关触发点)。
|
||||
2. 需要 app 数据时走 Proxy,**禁止** app use distribution 类。
|
||||
3. Extend 事件只追加命名空间字段,不覆盖核心 list 项。
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心业务纪律
|
||||
|
||||
### 7.1 快照与幂等
|
||||
|
||||
- 订单创建/支付:把当时的关系、等级、比例写入**快照表**(自有表),避免事后改等级影响历史订单。
|
||||
- 支付监听:同一 `order_no` 佣金入账幂等。
|
||||
- 退款:按快照回滚,禁止简单 delete 无审计。
|
||||
|
||||
### 7.2 结算 vs 打款
|
||||
|
||||
| 阶段 | 位置 |
|
||||
| --- | --- |
|
||||
| 佣金冻结、可提现已解冻、审核 | distribution Services |
|
||||
| 微信/支付宝「付款到零钱」 | pay 驱动 `merchantPay` + PayServices 分支(可能需核心配合,见 pay 分册) |
|
||||
|
||||
不要在 distribution 里直接 curl 支付;走已有 Pay 门面。
|
||||
|
||||
### 7.3 与 product/trade
|
||||
|
||||
- 商品是否参与分销:读 `product_center.is_sub` 等中台字段,由 product Handler/中台维护,分销模块读快照。
|
||||
- 活动订单:commission 基数 usually 实付;活动价由 trade 已写入订单项,分销 listener 读 order_item 快照。
|
||||
|
||||
---
|
||||
|
||||
## 8. Installer
|
||||
|
||||
- 分销等级、规则、菜单、默认配置:自有表 + seed。
|
||||
- 菜单挂核心分销父级(与现役 Installer `getMenuConfig` 对齐)。
|
||||
- 卸载:清理佣金配置菜单、自有扩展表(**注意**:生产卸载通常禁止,仅隔离库验证)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 路由与 Controller
|
||||
|
||||
- Admin:分销员、等级、事业部、佣金明细、提现审核。
|
||||
- User:推广中心、佣金、提现申请、海报/链接。
|
||||
- 权限:敏感操作 `v-auth` + 后端角色校验;提现审核防 IDOR(仅可操作本人或授权范围)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端契约
|
||||
|
||||
- 管理端:`pages/plugin/distribution/` UMD 扩展。
|
||||
- 移动端:推广海报、佣金页;扩展数据走 API,不 fork 核心「我的」页类型分支。
|
||||
- 订单/用户详情扩展:后端 Extend 事件注入,前端读 `extensions.distribution_*`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 逐步扩展(在 distribution 内)
|
||||
|
||||
1. 读清 `event.php` 现有网,避免重复监听同一事件做冲突逻辑。
|
||||
2. 新规则需要的新表:`Installer::runMigrate()` 幂等。
|
||||
3. 业务写在 `service/`,listener 薄(解析 payload → 调 Service)。
|
||||
4. 若 app 需调用:扩展 `DistributionServiceProxyListener` 白名单。
|
||||
5. 列表/详情:Extend listener 只追加字段。
|
||||
6. 通知:复用 `UserBrokerageChange` 等或新增模块内触发 + Notice 种子。
|
||||
7. 退款/售后:找订单退款成功触发点挂回滚 listener。
|
||||
8. 对账脚本/导出:Admin API,禁止裸 SQL 在 Controller。
|
||||
9. 验收 + 打包整个 `modules/distribution`(通常与客户定制一起交付)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 验收清单
|
||||
|
||||
### 12.1 静态
|
||||
|
||||
```bash
|
||||
cd backend && php think plugin:manifest-check && php think arch:check
|
||||
```
|
||||
|
||||
### 12.2 全链路(隔离库)
|
||||
|
||||
- [ ] 绑粉/注册:关系正确、循环绑定拒绝
|
||||
- [ ] 下单:`OrderCreateAfter` 快照存在
|
||||
- [ ] 支付:`OrderPaySuccess` 佣金与比例正确、幂等
|
||||
- [ ] 退款:佣金回滚与订单状态一致
|
||||
- [ ] 提现:审核流完整;打款走 pay(若启用)
|
||||
- [ ] 订单/用户 Extend:前后端字段一致
|
||||
- [ ] 禁用分销插件:核心商城可下单(无 fatal);代理返回空
|
||||
- [ ] 卸载:菜单与自有数据按策略清理(仅测试库)
|
||||
|
||||
### 12.3 对账
|
||||
|
||||
- [ ] 佣金流水 = 订单实付 × 比例(抽样)
|
||||
- [ ] 同一 order_no 无重复入账
|
||||
|
||||
---
|
||||
|
||||
## 13. 打包
|
||||
|
||||
分销通常为单一大模块:打包 `backend/modules/distribution/` + admin plugin 前端 → `.plugin-packages/distribution-{version}.zip`。
|
||||
|
||||
子功能不应拆成多个 key unless 产品明确要求独立装卸(现役无此拆分)。
|
||||
|
||||
---
|
||||
|
||||
## 14. 禁止事项
|
||||
|
||||
- 禁止同级模块 `use modules\distribution\*`(应 event/Proxy)。
|
||||
- 禁止在 marketing 模块写佣金入账。
|
||||
- 禁止改 `order_center` 核心字段语义来「带佣金」;用快照表或 Extend。
|
||||
- 禁止提现接口跳过权限校验。
|
||||
- 禁止把密钥写进 plugin.json。
|
||||
|
||||
---
|
||||
|
||||
## 15. 排错
|
||||
|
||||
| 现象 | 查 |
|
||||
| --- | --- |
|
||||
| 无佣金 | OrderPaySuccess 是否触发;listener 是否启用;商品 is_sub |
|
||||
| 重复佣金 | 幂等键;支付回调重试 |
|
||||
| 详情无分销字段 | OrderCenterDetailExtend 监听;插件启用 |
|
||||
| app 调不到分销 | DistributionServiceProxy 白名单 method |
|
||||
|
||||
---
|
||||
|
||||
## 16. 与 marketing / trade 边界(小结)
|
||||
|
||||
```text
|
||||
trade → 成交价、活动库存
|
||||
marketing → 券/积分/抽奖(弱对账)
|
||||
distribution → 关系 + 佣金快照 + 结算/提现(强对账)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 参考阅读
|
||||
|
||||
- 订单中台扩展:[订单中台规范](../../../../rules/订单中台规范.md)
|
||||
- 支付打款:[01-驱动-pay-支付](01-驱动-pay-支付.md) merchantPay 节
|
||||
- 真源事件列表:`backend/modules/distribution/event.php`
|
||||
|
||||
---
|
||||
|
||||
## 18. DistributionServiceProxy 使用约定
|
||||
|
||||
app 层需要读分销配置、判断用户是否推广员、查询某订单快照时,应触发 `DistributionServiceProxy`,由 `DistributionServiceProxyListener` 按 **method 白名单** 分发到 `modules/distribution/service/*`。
|
||||
|
||||
插件二开扩展分销能力时:
|
||||
|
||||
1. 在 listener 中增加 case,不要新增 app → modules 的 use。
|
||||
2. 请求体建议固定 `{ "method": "xxx", "params": { ... } }` 形状,与现役实现对齐后再扩展。
|
||||
3. 未安装/禁用分销模块时 listener 不注册,app 侧必须容忍空结果。
|
||||
|
||||
这与 `MarketingServiceProxy`、`ChannelServiceProxy` 同构,是扁平大模块对外唯一推荐的 programmatic 入口(除 Extend 事件外)。
|
||||
@ -0,0 +1,286 @@
|
||||
# 业务模块分册 · 营销玩法(marketing)
|
||||
|
||||
核对日期:2026-11-06(按 `backend/modules/marketing/` 现役代码核对)。代码与本文冲突时以代码为准。
|
||||
|
||||
**先读**:[00-总览-类型路由与边界](00-总览-类型路由与边界.md) 第 4 节;领域判定:[domain-selection.md](../domain-selection.md);事件与代理:[registration-and-contracts.md](../registration-and-contracts.md)。
|
||||
|
||||
强制红线:[插件开发规范](../../../../rules/插件开发规范.md)、[事件规范](../../../../rules/事件规范.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 领域判定:口诀与反例
|
||||
|
||||
### 1.1 口诀
|
||||
|
||||
**观察用户/订单行为发放奖励**,或运行**不改变成交契约**的独立运营活动 → `backend/modules/marketing/{name}/`。
|
||||
|
||||
核心特征:卸载或禁用后,**商品仍可按原价下单、支付、履约**;只是不再累计抽奖次数、不再发新人券、不再签到送积分。
|
||||
|
||||
### 1.2 应归入 marketing
|
||||
|
||||
| 场景 | 现役模块 | 触发方式 |
|
||||
| --- | --- | --- |
|
||||
| 抽奖次数、开奖 | `marketing/lottery` | `OrderPayLottery`、`OrderComment` 等 |
|
||||
| 签到奖励 | `marketing/signin` | 用户行为 API + 定时提醒 |
|
||||
| 新人注册礼 | `marketing/newuser` | `UserRegister` |
|
||||
| 充值活动(充值单语义若独立需单独设计) | `marketing/recharge` | 充值成功事件 |
|
||||
| 渠道码统计 | `marketing/channel_code` | 扫码/绑定事件 |
|
||||
|
||||
### 1.3 反例
|
||||
|
||||
| 误判 | 正确落位 |
|
||||
| --- | --- |
|
||||
| 秒杀价、拼团价必须在下单时生效 | `trade` + `TradeModeRegistry` |
|
||||
| 卖新形态商品(卡密、课程) | `product` |
|
||||
| 微信小程序登录 | `channel` |
|
||||
| 分销佣金快照 | `distribution` |
|
||||
|
||||
### 1.4 与 trade 的硬分界
|
||||
|
||||
若移除模块后,用户仍能以「活动价」完成支付,说明成交价逻辑还留在 core 或 product,**不是** marketing 问题,应把计价迁回 trade Handler。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现役模块对照
|
||||
|
||||
| 目录 | plugin key | type | App::register | 复杂度 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `marketing/lottery` | `activity_lottery` | `marketing` | 空 | 高(后台+事件+通知) |
|
||||
| `marketing/signin` | `user_signin` | `marketing` | CrontabTaskRegistry | 中 |
|
||||
| `marketing/newuser` | `marketing_newuser` | `marketing` | 空 | 低(单监听) |
|
||||
| `marketing/recharge` | `user_recharge` | `marketing` | 视实现 | 中 |
|
||||
| `marketing/channel_code` | `user_channel_code` | `marketing` | 视实现 | 中 |
|
||||
|
||||
说明:现役 key 命名不统一(`activity_*` / `user_*` / `marketing_*`)。**新模块建议** `marketing_{name}` 或对齐业务已有菜单前缀,但五处 key 必须自洽。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐参考
|
||||
|
||||
| 目标 | 参考 |
|
||||
| --- | --- |
|
||||
| 完整后台 + 多事件 + 服务代理 | `marketing/lottery` |
|
||||
| 最小事件插件 | `marketing/newuser`(仅 `UserRegister`) |
|
||||
| 定时任务 + seed crontab | `marketing/signin` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录树
|
||||
|
||||
```text
|
||||
backend/modules/marketing/{name}/
|
||||
├── plugin.json # type=marketing
|
||||
├── App.php # register 多为空或仅 CrontabTaskRegistry
|
||||
├── Installer.php
|
||||
├── event.php # ★ 核心:listen 映射
|
||||
├── listener/ # ★ 核心:*Listener.php
|
||||
├── controller/admin|user/ # 有运营后台时
|
||||
├── route/
|
||||
├── service/ dao/ model/ validate/
|
||||
├── jobs/ # 异步发奖、批量通知
|
||||
├── crontab/ # 签到提醒等
|
||||
└── schema/ # 少数模块放 SQL 片段(按需)
|
||||
|
||||
frontend/admin/src/pages/plugin/{key}/
|
||||
```
|
||||
|
||||
**没有**统一的 `MarketingRegistry`;不要臆造 Handler 接口,以**事件订阅 + 自有表**为主。
|
||||
|
||||
---
|
||||
|
||||
## 5. plugin.json
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "marketing_example",
|
||||
"name": "示例营销",
|
||||
"description": "不改变订单成交契约的奖励/活动",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "marketing",
|
||||
"path": "modules/marketing/example",
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. App.php
|
||||
|
||||
### 6.1 典型(仅事件)
|
||||
|
||||
`marketing/lottery/App.php`:`register()` 为空,仅 `boot()` 加载 `event.php`。
|
||||
|
||||
### 6.2 含定时任务
|
||||
|
||||
`marketing/signin/App.php`:
|
||||
|
||||
```php
|
||||
public static function register(ThinkApp $app): void
|
||||
{
|
||||
$app->make(CrontabTaskRegistry::class)->register('signRemind', SignRemindTask::class);
|
||||
}
|
||||
```
|
||||
|
||||
任务类实现 `CrontabTaskInterface`:`getMark()`、`getName()`、`getDescription()`、`handle()`(幂等)。
|
||||
|
||||
---
|
||||
|
||||
## 7. event.php 契约
|
||||
|
||||
格式(ThinkPHP):
|
||||
|
||||
```php
|
||||
return [
|
||||
'listen' => [
|
||||
'OrderPaySuccess' => [
|
||||
\modules\marketing\example\listener\OrderPaidListener::class,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 7.1 常用触发事件(节选,完整见事件规范)
|
||||
|
||||
| 事件名 | 典型用途 |
|
||||
| --- | --- |
|
||||
| `UserRegister` | 新人礼(newuser) |
|
||||
| `OrderPaySuccess` / `OrderPayLottery` | 支付后发奖(lottery 用专用名) |
|
||||
| `OrderComment` | 评价后抽奖次数 |
|
||||
| `MarketingServiceProxy` | app 层方法代理,避免 use modules |
|
||||
|
||||
### 7.2 lottery 真源示例
|
||||
|
||||
`marketing/lottery/event.php` 注册:
|
||||
|
||||
- `OrderPayLottery`、`OrderComment`、`OrderCommentLotteryCheck`
|
||||
- `MarketingServiceProxy`
|
||||
- `Lottery`(通知链)
|
||||
|
||||
新增营销能力时,**优先复用已有事件名**;若需新事件,须在 `app` 层合法触发点 `event('YourEvent', $payload)` —— 这通常属于核心改动,插件二开应尽量避免;改用已有钩子或 Proxy 模式。
|
||||
|
||||
### 7.3 监听器编写纪律
|
||||
|
||||
- 幂等:同一订单/同一用户同一活动只发一次(唯一键/状态机)。
|
||||
- 失败:除强制校验外,捕获异常打日志,**不要**让无关 API 整体失败。
|
||||
- 扩展共享数组:用命名空间 key(如 `extensions.marketing_lottery`),勿覆盖核心字段。
|
||||
- 代理监听:参考 `MarketingServiceProxyListener`,白名单方法 + 稳定请求/响应结构。
|
||||
|
||||
---
|
||||
|
||||
## 8. Service 代理模式(与 app 协作)
|
||||
|
||||
app 层不得 `use modules\marketing\*`。需要读抽奖次数、签到状态时:
|
||||
|
||||
1. app Service 内 `event('MarketingServiceProxy', $request)` 或专用 Query 事件。
|
||||
2. marketing 模块 `listener` 识别 `method` / `action` 白名单,返回数据。
|
||||
3. 未安装插件时 app 得到空/default,流程继续。
|
||||
|
||||
distribution、channel 有同构的 `DistributionServiceProxy`、`ChannelServiceProxy`。
|
||||
|
||||
---
|
||||
|
||||
## 9. Installer
|
||||
|
||||
- **自有表**:奖励记录、活动配置、库存奖品等,`eb_{业务}_*` 或模块约定前缀。
|
||||
- **菜单**:营销父级 `parent_key: core:admin-marketing`(与 lottery 对齐)。
|
||||
- **seed**:字典、通知模板(若模块内定义 `getNoticeDefinitions` 类——视现役模块而定)、`system_crontab`。
|
||||
- **cleanData**:删本插件菜单、链接分类、crontab mark、自有配置行。
|
||||
|
||||
充值类(recharge)可能涉及支付 attach 路由,仍不得改 `PayServices` 核心分支,通过已有充值流程 + 事件扩展。
|
||||
|
||||
---
|
||||
|
||||
## 10. 路由与 Controller
|
||||
|
||||
- 运营配置、记录查询、手动补发:Admin API。
|
||||
- C 端:抽奖、签到、领取 — User API。
|
||||
- 中间件、响应体规范同 00 总览;权限 `auth_type=2` 与路由一致。
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端契约
|
||||
|
||||
- 管理端:plugin 页面配置规则与记录。
|
||||
- 移动端:新独立页放 `pages-modules/`,放入后的跳转与装修见 [uniapp-pages.md](../uniapp-pages.md)。弹窗/浮层优先由 API 返回开关与次数。
|
||||
- DIY:`diy.components` 声明组件名;管理端组件 `pluginComponent: true`。
|
||||
|
||||
---
|
||||
|
||||
## 12. 逐步落地
|
||||
|
||||
1. 明确**触发点**(注册/支付/评价)与**奖励物**(积分、券、次数)。
|
||||
2. 确认不属于 trade 计价 → 读 domain-selection 再动手。
|
||||
3. 复制 **newuser** 或 **lottery** 骨架。
|
||||
4. 设计自有表 + 幂等键(`uid+order_id+activity_id` 等)。
|
||||
5. 写 `event.php` + `listener`(先实现主路径)。
|
||||
6. `Installer` 菜单 + migrate。
|
||||
7. 若需定时:`crontab` + Registry + seed。
|
||||
8. 若 app 要查询:加 Proxy listener,文档化 request 结构。
|
||||
9. Admin/User 路由与页面。
|
||||
10. 通知(可选):监听模块内 Services 触发的 `Lottery` 类事件或 Notice 种子。
|
||||
11. 验收:禁用插件后核心下单不受影响。
|
||||
12. 打包。
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收清单
|
||||
|
||||
### 13.1 静态
|
||||
|
||||
```bash
|
||||
cd backend && php think plugin:manifest-check && php think event:check
|
||||
```
|
||||
|
||||
### 13.2 行为
|
||||
|
||||
- [ ] 触发事件后奖励正确入账(表记录 + 用户可见)
|
||||
- [ ] 重复触发不重复发(幂等)
|
||||
- [ ] 并发/重试安全(数据库唯一约束或 CAS)
|
||||
- [ ] 禁用插件:触发点不 500;app 代理返回空
|
||||
- [ ] 卸载:自有表/菜单/定时任务清理(隔离库)
|
||||
|
||||
### 13.3 边界
|
||||
|
||||
- [ ] 未把活动价写进 marketing listener
|
||||
- [ ] 未 use 其他 marketing/trade 模块
|
||||
- [ ] 无 `app/`、`crmeb/` diff
|
||||
|
||||
---
|
||||
|
||||
## 14. 打包
|
||||
|
||||
压缩 `modules/marketing/{name}/` + admin plugin 前端 → `.plugin-packages/`。详见插件开发规范第七节。
|
||||
|
||||
---
|
||||
|
||||
## 15. 禁止事项
|
||||
|
||||
- 禁止用 marketing 伪装 trade(在 listener 里改 order_item 价格)。
|
||||
- 禁止监听器抛未捕获异常导致支付回调失败(除非产品明确要求强一致且用户同意改 core)。
|
||||
- 禁止直接写 `system_config` 存活动规则(用自有表 + Installer seed)。
|
||||
- 禁止跨模块 use;需要能力走事件/Proxy。
|
||||
|
||||
---
|
||||
|
||||
## 16. 排错
|
||||
|
||||
| 现象 | 查 |
|
||||
| --- | --- |
|
||||
| 从不触发 | 事件名拼写;插件是否启用;触发方是否 `event()` |
|
||||
| 重复发奖 | 幂等键;事务边界 |
|
||||
| 后台看不到菜单 | getMenuConfig;安装是否成功 |
|
||||
| 定时未跑 | system_crontab 是否有 mark;is_open;CrontabTaskRegistry 是否 register |
|
||||
|
||||
---
|
||||
|
||||
## 17. 概念图
|
||||
|
||||
```text
|
||||
app Services ──event('OrderPayLottery')──► marketing/lottery/listener
|
||||
│
|
||||
└──► 写 lottery 次数表(不改 order_center.pay_price)
|
||||
|
||||
trade/seckill ──► 改成交价(validateOrder + getActivityPrice)
|
||||
marketing ──► 改用户权益(积分/券/次数),不改成交价
|
||||
```
|
||||
@ -0,0 +1,430 @@
|
||||
# 业务模块分册 · 产品类型(product)
|
||||
|
||||
核对日期:2026-11-06(按 `backend/modules/product/` 现役代码核对)。代码与本文冲突时以代码为准。
|
||||
|
||||
**先读**:[00-总览-类型路由与边界](00-总览-类型路由与边界.md) 第 4 节(模块通用机制:发现、Installer、路由、打包、校验命令)。领域归属判定:[domain-selection.md](../domain-selection.md)。注册与契约细节:[registration-and-contracts.md](../registration-and-contracts.md)。
|
||||
|
||||
强制红线真源:[插件开发规范](../../../../rules/插件开发规范.md)、[架构规范](../../../../rules/架构规范.md)。本篇只写 product 域「怎么做」,不重复 00 里的通用安装/打包流程正文。
|
||||
|
||||
---
|
||||
|
||||
## 1. 领域判定:口诀与反例
|
||||
|
||||
### 1.1 口诀
|
||||
|
||||
**改变「卖什么」**——商品形态、SKU/库存维度、配送或履约方式、详情展示块、产品专属售后资格 → 落 `backend/modules/product/{type}/`,在 `App::register()` 里登记 `ProductTypeRegistry`(通常还要 `OrderTypeRegistry` + `AfterSaleRuleRegistry`)。
|
||||
|
||||
与 **trade(怎么卖)** 正交:产品 Handler 用 `canCombineWith($tradeMode)` 和 `getCompatibility()` 声明能和哪些交易模式组合;交易模式负责活动价与下单校验,产品类型不负责秒杀价。
|
||||
|
||||
### 1.2 应归入 product 的典型需求
|
||||
|
||||
| 需求特征 | 示例(现役或文档 walkthrough) |
|
||||
| --- | --- |
|
||||
| 新 `product_type` 值,中台 `product_center.product_type` 存你的 typeKey | `normal` / `virtual` / `cardkey`;文档中的 `course` |
|
||||
| 自有扩展表存形态字段,支付后走专属履约 | 卡密池 `pt_cardkey_stock`、虚拟网盘字段 |
|
||||
| 需要独立订单业务表 + 专属状态树 | `cardkey_order` + `CardkeyOrderAdapter` |
|
||||
| 售后规则与实物不同(仅退款、核销后不可退等) | `CardkeyAfterSaleHandler` |
|
||||
|
||||
### 1.3 反例(不要做成 product)
|
||||
|
||||
| 误判 | 正确落位 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 「下单时必须校验拼团是否成团」 | `modules/trade/combination` | 改变成交契约,属于交易模式 Handler |
|
||||
| 「支付成功送积分/抽奖次数」 | `modules/marketing/*` | 去掉后订单语义不变 |
|
||||
| 「接微信支付 SDK」 | `driver/pay/*` | 第三方协议层 |
|
||||
| 「小程序 openid 登录」 | `modules/channel/wechat_mini` | 端渠道能力 |
|
||||
| 在 `app/services/order/*` 里加 `if ($type=='xxx')` | 应实现 Handler + 注册 | 零侵入:禁止改中央 Service 类型分支 |
|
||||
|
||||
### 1.4 仍不确定时
|
||||
|
||||
1. 读 [domain-selection.md](../domain-selection.md)「产品类型与交易模式」小节。
|
||||
2. 问:**禁用这个插件后,普通商品还能不能照常下单支付?** 若不能,多半是 product;若只是少发奖,是 marketing。
|
||||
3. 再问:**去掉它后活动价/限购逻辑是否仍残留在订单里?** 若是,可能是 trade 没拆干净。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现役模块对照
|
||||
|
||||
| 目录 | `plugin.json` key | typeKey | order sourceType | 结构特点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `product/normal` | `product_normal` | `normal` | `normal_order` | 物流、运费、`ProductDetailExtend` / 列表扩展 |
|
||||
| `product/virtual` | `product_virtual` | `virtual` | `virtual_order` | 自动发货、详情履约预览事件 |
|
||||
| `product/cardkey` | `product_cardkey` | `cardkey` | `cardkey_order` | 卡密库存池、支付后发卡、`canCombineWith` 仅 `normal` |
|
||||
|
||||
命名约定(与 00 一致):
|
||||
|
||||
- 清单 `type` 固定 `product_type`。
|
||||
- 清单 `key` 建议 `product_{目录名}`,与 `Installer::getKey()`、菜单 `unique_auth` 前缀、管理端 `pages/plugin/<key>/` 一致。
|
||||
- Handler `getTypeKey()` 与 `product_center.product_type` 存值一致(通常等于目录名,如 `cardkey`)。
|
||||
- `getOrderSourceType()` 与 `OrderTypeAdapter::getSourceType()` 一致(如 `cardkey_order`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐参考模块(复制谁)
|
||||
|
||||
| 你要做的形态 | 优先复制 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 数字内容、支付后自动交付、无物流 | `product/cardkey` 或 `product/virtual` | 履约在 `fulfillOrder`,库存可虚拟 |
|
||||
| 实物 + 快递 + 运费模板 | `product/normal` | 运费、`requiresShipping`、列表扩展 |
|
||||
| 从 0 学三维注册 | `product/cardkey/App.php` + `Handler.php` 前 120 行 | 注册最清晰、注释完整 |
|
||||
|
||||
复制后必改三处身份:`namespace`、`plugin.json` 的 `key`/`path`、`Handler` 的 typeKey/sourceType。不要复制 `ensureCenterTables()` 除非你的类型确实需要在中台表缺失时自愈(卡密安装器内含大段 DDL,新类型可改为依赖全局迁移,只建 `pt_*` 自有表)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 标准目录树
|
||||
|
||||
```text
|
||||
backend/modules/product/{type}/
|
||||
├── plugin.json # key=product_{type}, type=product_type, path=modules/product/{type}
|
||||
├── App.php # register: 三维 Registry;boot: loadEvent(event.php)
|
||||
├── Installer.php # BaseModuleInstaller:表/菜单/seed/clean
|
||||
├── Handler.php # extends BaseProductTypeHandler
|
||||
├── event.php # 可选:ProductDetailExtend、OrderPaySuccess 等
|
||||
├── handler/
|
||||
│ ├── {Type}OrderAdapter.php # implements OrderTypeAdapterInterface
|
||||
│ └── {Type}AfterSaleHandler.php
|
||||
├── controller/
|
||||
│ ├── admin/ # *Controller.php
|
||||
│ └── user/v1/ # 用户端(与现役模块对齐)
|
||||
├── route/
|
||||
│ ├── admin.php # 挂到 /adminapi/{prefix}/
|
||||
│ └── user.php # 挂到 /api/
|
||||
├── service/ # *Services.php
|
||||
├── dao/ # *Dao.php
|
||||
├── model/ # *Model.php(或项目约定后缀)
|
||||
├── validate/ # 按需
|
||||
├── listener/ # 扩展事件监听器
|
||||
├── jobs/ # 异步履约、补发(按需)
|
||||
├── crontab/ # 定时任务(按需,须 CrontabTaskRegistry + seed)
|
||||
└── pipeline/ # 履约管线(按需,normal 等可能有)
|
||||
|
||||
frontend/admin/src/pages/plugin/product_{type}/ # ext.json + 源码;构建 UMD 见 admin-extension.md
|
||||
```
|
||||
|
||||
未启用或未安装的插件:`ModuleRegistry` 不会调用 `App::register()`,`ModuleRouteLoader` 不会加载其路由。
|
||||
|
||||
---
|
||||
|
||||
## 5. plugin.json
|
||||
|
||||
最小可安装清单(与 `product/cardkey/plugin.json` 同结构):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "product_yourtype",
|
||||
"name": "你的商品类型",
|
||||
"description": "一句话说明形态与履约",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "product_type",
|
||||
"path": "modules/product/yourtype",
|
||||
"require": {
|
||||
"crmeb": "^8.0",
|
||||
"php": "^8.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可选扩展(按需,见 [manifest-and-lifecycle.md](../manifest-and-lifecycle.md)):
|
||||
|
||||
- `diy.components`:移动端 DIY 组件白名单。
|
||||
- `default_install`:是否随系统默认安装(需与产品策略一致)。
|
||||
|
||||
**五处 key 必须相同**:`plugin.json.key`、`Installer::getKey()`、后台菜单权限标识、前端 `pages/plugin/<key>/`、打包 zip 文件名中的 key。
|
||||
|
||||
首版阶段 `version` 保持 `1.0.0`,与 `Installer::getVersion()`(若覆写)一致;见架构规范「当前发布阶段」。
|
||||
|
||||
---
|
||||
|
||||
## 6. App.php:register 与 boot
|
||||
|
||||
### 6.1 register(三维注册)
|
||||
|
||||
现役卡密示例(真源 `backend/modules/product/cardkey/App.php`):
|
||||
|
||||
```php
|
||||
public static function register(ThinkApp $app): void
|
||||
{
|
||||
$app->make(ProductTypeRegistry::class)->register('cardkey', Handler::class);
|
||||
OrderTypeRegistry::register('cardkey_order', CardkeyOrderAdapter::class);
|
||||
AfterSaleRuleRegistry::registerProductType('cardkey', CardkeyAfterSaleHandler::class);
|
||||
}
|
||||
```
|
||||
|
||||
| 注册调用 | 回答的问题 | 漏注册的典型故障 |
|
||||
| --- | --- | --- |
|
||||
| `ProductTypeRegistry::register($typeKey, Handler::class)` | 中台能否创建/识别该 `product_type` | 后台选不了类型;`get()` 回退默认 Handler |
|
||||
| `OrderTypeRegistry::register($sourceType, Adapter::class)` | 订单列表/详情状态、按钮、进度 | 详情状态错乱或按钮缺失 |
|
||||
| `AfterSaleRuleRegistry::registerProductType($typeKey, AfterSale::class)` | 退款/退货资格与方案 | 售后入口错误或被中央默认规则覆盖 |
|
||||
|
||||
`OrderTypeRegistry::register` 是静态方法;`ProductTypeRegistry` 从容器 `make`(与 `AppService` 绑定单例一致)。
|
||||
|
||||
可选:在 `register()` 里 `$app->make(CrontabTaskRegistry::class)->register($mark, $taskClass)`(参考 marketing/signin,产品域若有定时取消/过期任务)。
|
||||
|
||||
### 6.2 boot(事件)
|
||||
|
||||
```php
|
||||
public static function boot(ThinkApp $app): void
|
||||
{
|
||||
$eventFile = __DIR__ . '/event.php';
|
||||
if (is_file($eventFile)) {
|
||||
$event = include $eventFile;
|
||||
if (is_array($event)) {
|
||||
$app->loadEvent($event);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**禁止**在 `backend/app/AppService.php` 写死本模块类名;扩展只走 Registry 与本模块 `App.php`。
|
||||
|
||||
---
|
||||
|
||||
## 7. Handler 契约(方法级)
|
||||
|
||||
接口:`backend/crmeb/trade/product/ProductTypeHandlerInterface.php`(32 个方法)。默认实现:`BaseProductTypeHandler.php`。**只覆盖与你形态相关的方法**,其余继承默认。
|
||||
|
||||
### 7.1 身份与策略(必覆盖)
|
||||
|
||||
| 方法 | 作用 | 卡密示例要点 |
|
||||
| --- | --- | --- |
|
||||
| `getTypeKey(): string` | 中台 `product_type` | `'cardkey'` |
|
||||
| `getTypeName(): string` | 后台展示名 | `'卡密商品'` |
|
||||
| `getOrderSourceType(): string` | 订单 source_type,与 Adapter 一致 | `'cardkey_order'` |
|
||||
| `getCapabilities(): array` | 能力声明(购物车、规格等) | 按形态返回 |
|
||||
| `getPricingPolicy(): array` | 计价策略提示 | 默认即可或覆盖 |
|
||||
| `getRequiredFields(): array` | 创建商品必填扩展字段 | 卡密为空数组 |
|
||||
| `canCombineWith(string $tradeMode): bool` | 与交易模式兼容性 | 卡密仅 `normal` |
|
||||
| `getCompatibility(): array` | 结构化兼容说明 | 供前端/校验 |
|
||||
| `isVirtual(): bool` | 是否虚拟 | 卡密/虚拟为 true |
|
||||
| `requiresShipping(): bool` | 是否要地址物流 | normal 为 true |
|
||||
| `supportsPickup(): bool` | 自提 | 按形态 |
|
||||
| `supportsReturn(): bool` | 是否支持退货(能力,非规则细节) | 调用方不得硬编码 type 名 |
|
||||
|
||||
### 7.2 商品 CRUD 与详情
|
||||
|
||||
| 方法 | 作用 |
|
||||
| --- | --- |
|
||||
| `afterProductSave(int $productId, array $data): void` | 保存后写扩展表 |
|
||||
| `afterProductDelete(int $productId): void` | 删商品清理扩展数据 |
|
||||
| `saveFromAdmin(int $id, array $data): ?int` | 后台保存入口(复杂形态可委托 Service) |
|
||||
| `getAdminExtendInfo(int $productId): array` | 后台编辑页扩展块 |
|
||||
| `getDetailExtend(int $productId): array` | 详情扩展(也可用事件 `ProductDetailExtend`) |
|
||||
| `getDetailSpecs(int $productId): array` | SKU 规格列表 |
|
||||
| `getListExtend(int $productId): array` | 列表卡片扩展 |
|
||||
| `getPageConfig(string $scene, string $appVersion): array` | 端上页面配置/sections |
|
||||
| `getOrderFormComponents(): array` | 下单表单额外组件 |
|
||||
| `getSelectionSpec(): array` | 选品规格说明 |
|
||||
| `getCartStrategy(): array` | 加购策略 |
|
||||
| `getSkuExtraFields(): array` | SKU 编辑器额外字段 |
|
||||
|
||||
### 7.3 库存与 SKU
|
||||
|
||||
| 方法 | 作用 |
|
||||
| --- | --- |
|
||||
| `getSkuInfo(int $productId, string $skuUnique): ?array` | 下单/购物车取价与库存 |
|
||||
| `decStock` / `incStock` | 扣减/回滚库存(卡密可能扣「未使用卡」计数) |
|
||||
| `getShippingInfo(int $productId): array` | 运费相关(实物) |
|
||||
|
||||
### 7.4 订单生命周期
|
||||
|
||||
| 方法 | 作用 |
|
||||
| --- | --- |
|
||||
| `fulfillOrder(string $orderNo, array $orderInfo): bool` | 支付后履约(发卡、开权限、发网盘链接) |
|
||||
| `getOrderExtend(string $orderNo): array` | 订单详情扩展 |
|
||||
| `getOrderDetailRoute(string $orderNo): string` | 端上详情路由 |
|
||||
| `getAfterSaleRule(): AfterSaleHandlerInterface` | 可委托独立 AfterSaleHandler 类 |
|
||||
|
||||
履约失败应打日志并返回 `false`;是否阻断支付回调由中台策略决定,Handler 内不要直接改支付状态。
|
||||
|
||||
### 7.5 扩展数据:优先事件而非改 app
|
||||
|
||||
现役 `product/virtual/event.php` 监听:
|
||||
|
||||
- `ProductDetailExtend` — 详情 `extensions.*`
|
||||
- `OrderCenterListExtend` / `OrderCenterDetailExtend` — 订单列表/详情
|
||||
|
||||
`SafeEvent` 对部分扩展事件做了安全包装;监听器内捕获业务异常,**不要让无关请求整体 500**(除非该事件属于强制校验)。
|
||||
|
||||
---
|
||||
|
||||
## 8. OrderTypeAdapter(方法级)
|
||||
|
||||
接口:`backend/crmeb/trade/order/type/OrderTypeAdapterInterface.php`。
|
||||
|
||||
| 方法 | 作用 |
|
||||
| --- | --- |
|
||||
| `getSourceType(): string` | 静态,与 Handler `getOrderSourceType()` 一致 |
|
||||
| `getModelClass(): string` | 源订单 Model(如 `pt_cardkey_order` 对应 Model) |
|
||||
| `getOrderCategory()` / `getOrderCategoryLabel()` | 订单分类展示 |
|
||||
| `resolveStatusCode(array $sourceOrder): string` | 映射到中台统一 status_code |
|
||||
| `resolvePayStatus` / `resolveRefundStatus` | 支付/退款状态 |
|
||||
| `resolveBusinessStatus(array $sourceOrder): ?array` | 业务子状态(可选) |
|
||||
| `getStatusButtons(string $statusCode): array` | 按状态可用操作 |
|
||||
| `getOrderButtons(array $detail): array` | 详情页按钮 |
|
||||
| `getProgress(array $detail): array` | 进度条节点 |
|
||||
| `advance(string $orderNo, string $extStatus, array $extra): void` | 推进业务状态(确认收货、核销等) |
|
||||
|
||||
Adapter 只翻译**已有源表**记录,不要在 Adapter 里创建订单;创建在 `fulfillOrder` 或支付监听里完成。
|
||||
|
||||
---
|
||||
|
||||
## 9. AfterSaleHandler(方法级)
|
||||
|
||||
接口:`AfterSaleHandlerInterface`(规则 + 生命周期 + 退款方案)。
|
||||
|
||||
核心方法:
|
||||
|
||||
- `getRule($orderCenter, $sourceOrder): array` — `allow`、`available_types`、`deadline` 等。
|
||||
- `buildRefundPlan(...): array` — 退款方案固化,恢复任务不重算。
|
||||
- `beforeRefundSubmit` / `afterRefundComplete` 等 — 冻结卡密、撤销权益。
|
||||
|
||||
产品维度注册在 `AfterSaleRuleRegistry::registerProductType($typeKey, $class)`,与 trade 模式维度规则分开。
|
||||
|
||||
---
|
||||
|
||||
## 10. Installer:表、菜单、seed、清理
|
||||
|
||||
继承 `crmeb\module\BaseModuleInstaller`。
|
||||
|
||||
### 10.1 必实现
|
||||
|
||||
| 方法 | 要求 |
|
||||
| --- | --- |
|
||||
| `getKey()` / `getName()` / `getType()` | 与 plugin.json 一致;`getType()` 返回 `product_type` |
|
||||
| `runMigrate()` | `CREATE TABLE IF NOT EXISTS`,表名建议 `pt_{type}_*` 或业务前缀;**DDL 用 `$this->exec()`,不要包在大事务里** |
|
||||
| `rollbackMigrate()` | 卸载删自有表 |
|
||||
| `getMenuConfig()` | 菜单树;挂核心父级用 `parent_key: core:admin-product` 等 |
|
||||
|
||||
### 10.2 可选
|
||||
|
||||
- `seed()`:字典、定时任务 `system_crontab`、DIY `seedPageLinks`。
|
||||
- `cleanData()`:按 mark/分类名删 seed,禁删系统表。
|
||||
- `onEnable()` / `onDisable()`:必须 `parent::onEnable()`。
|
||||
|
||||
卡密安装器还调用 `ensureCenterTables()`:在新环境补 `product_center` 等中台表。新插件是否复制这段取决于部署是否保证全局迁移已跑;**不要对系统表做 ALTER 破坏其他类型**。
|
||||
|
||||
### 10.3 菜单与权限
|
||||
|
||||
- 可见菜单 `auth_type=1`。
|
||||
- 接口权限 `auth_type=2`,必须有 `api_url` + `methods`,与 `route/admin.php` 一致。
|
||||
- 按钮 `auth_type=3` + `unique_auth`。
|
||||
|
||||
路由示例前缀(卡密):`/adminapi/cardkey/product/list` → `ProductController::list`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 路由与 Controller 契约
|
||||
|
||||
- 文件:`route/admin.php`、`route/user.php`;`use think\facade\Route`。
|
||||
- Admin 中间件:对齐同域模块(`AllowOriginMiddleware`、`AdminAuthTokenMiddleware`、角色、日志)。
|
||||
- 控制器:**类名/文件名以 `Controller` 结尾**;构造注入 Service 时声明 `@var`。
|
||||
- 响应:统一 `{status,msg,data}`;列表 `{list,count}`。
|
||||
|
||||
路径、HTTP 方法、Controller 方法名(camelCase)、Installer 菜单 `api_url`、前端 `api/*.js` 函数名五处对齐。
|
||||
|
||||
---
|
||||
|
||||
## 12. 前端契约
|
||||
|
||||
- 管理端:仅改 `frontend/admin/src/pages/plugin/product_{type}/`(`ext.json` + 页面 + `npm run build` 产出 UMD)。见 [admin-extension.md](../admin-extension.md)。
|
||||
- 移动端:优先消费 Handler 的 `getPageConfig`、`display_sections`、订单 Adapter 的 `page_config.sections`;**不要改** uni-app 核心订单页 type 分支。
|
||||
- 独立页:源码放 `pages-modules/<标识>/`,用法见 [uniapp-pages.md](../uniapp-pages.md);Installer `ensurePageLinkCategory` + `seedPageLinks` 登记同一条路径。DIY 组件写 `plugin.json` → `diy.components`。
|
||||
|
||||
---
|
||||
|
||||
## 13. 逐步落地(建议顺序)
|
||||
|
||||
1. **定 typeKey、sourceType、plugin key**(如 `course` / `course_order` / `product_course`)。
|
||||
2. **复制参考模块目录**,改 namespace 与三处身份。
|
||||
3. **写 plugin.json + Installer::getKey/getType**,空菜单先能装。
|
||||
4. **设计自有表**(扩展表 + 可选订单业务表),`runMigrate()` 幂等。
|
||||
5. **实现 Handler 最小集**:身份、`getSkuInfo`、`decStock`/`incStock`、`fulfillOrder`、虚拟/物流布尔。
|
||||
6. **App::register 三维注册**,本地启用插件后跑 `php think registry:check`。
|
||||
7. **实现 OrderAdapter 状态树**(至少:待付款→待发货/已完成→售后)。
|
||||
8. **实现 AfterSaleHandler::getRule** 与必要退款钩子。
|
||||
9. **后台 route + Controller + Service**:建品、列表、详情。
|
||||
10. **event.php**(若需详情/列表扩展)。
|
||||
11. **管理端 plugin 页面 + api 封装**。
|
||||
12. **全链路手工验收**(下节)。
|
||||
13. **静态检查 + 打包** `.plugin-packages/`。
|
||||
|
||||
---
|
||||
|
||||
## 14. 全链路验收
|
||||
|
||||
### 14.1 静态(不写库也可)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
php think plugin:manifest-check
|
||||
php think registry:check
|
||||
php think arch:check # 只看新增违规
|
||||
php think event:check # 监听类存在
|
||||
```
|
||||
|
||||
### 14.2 生命周期(隔离库)
|
||||
|
||||
- [ ] 后台安装 → 启用 → 菜单与路由可访问
|
||||
- [ ] 禁用 → 路由不加载、中台不崩溃
|
||||
- [ ] 卸载 → 自有表/菜单/seed 清理(在隔离库测)
|
||||
|
||||
### 14.3 业务(产品专属)
|
||||
|
||||
- [ ] 后台创建该 `product_type` 商品并上架
|
||||
- [ ] 详情 API 含扩展块(Handler 或 `ProductDetailExtend`)
|
||||
- [ ] 加购/下单:库存扣减正确;不兼容的 trade 模式被 `canCombineWith` 拒绝
|
||||
- [ ] 支付成功 → `fulfillOrder` 执行(卡密发出/权限开通)
|
||||
- [ ] 订单中心:状态、按钮、进度与 Adapter 一致
|
||||
- [ ] 售后:规则与退款回滚库存/权益正确
|
||||
- [ ] 禁用本插件后,其他 product 类型不受影响
|
||||
|
||||
联调地址见工作区规则:管理端 `http://localhost:1619/admin`,接口基址 `http://192.168.1.169`。
|
||||
|
||||
---
|
||||
|
||||
## 15. 打包到 `.plugin-packages/`
|
||||
|
||||
与 [插件开发规范](../../../../rules/插件开发规范.md) 第七节一致:
|
||||
|
||||
1. 确认改动仅在 `backend/modules/product/{type}/` 与 `frontend/admin/src/pages/plugin/product_{type}/`。
|
||||
2. 前端扩展先构建 UMD。
|
||||
3. 压缩**插件根目录**(含 `plugin.json`),输出到仓库根 `.plugin-packages/{key}-{version}.zip`(目录 gitignore,现包现用)。
|
||||
4. 交付说明写:key、版本、参考模块、验收项、是否依赖其他插件。
|
||||
|
||||
---
|
||||
|
||||
## 16. 禁止事项(product 加强版)
|
||||
|
||||
- 禁止修改 `backend/app/`、`backend/crmeb/`、核心 uni-app/admin 源码、安装 SQL、composer、config。
|
||||
- 禁止 `modules\product\foo` use `modules\product\bar` 或 `modules\trade\*`;跨域走事件或 crmeb 契约。
|
||||
- 禁止在 Handler 里写活动价/秒杀库存(属于 trade Handler)。
|
||||
- 禁止虚构 `source_type`:中台已有 `normal_order` 时,活动型卖法用 trade 模式,不要为活动单独造 product type。
|
||||
- 禁止把密钥、第三方账号写进 plugin.json 或代码常量。
|
||||
- 禁止跳过三维注册中的任一维「以后再加」——上线前 `registry:check` 会报错或运行时不一致。
|
||||
|
||||
---
|
||||
|
||||
## 17. 排错速查
|
||||
|
||||
| 现象 | 优先查 |
|
||||
| --- | --- |
|
||||
| 后台没有「商品类型」选项 | 插件未启用;`ProductTypeRegistry` 未 register;typeKey 与保存时不一致 |
|
||||
| 下单报默认类型错误 | Handler `getSkuInfo` 返回 null;stock 未同步 |
|
||||
| 支付后未发卡 | `fulfillOrder` 是否实现;订单 source 表是否创建;支付事件是否触发履约 |
|
||||
| 订单详情按钮不对 | Adapter `getOrderButtons` / status 映射;source 表 status 字段值 |
|
||||
| 售后入口灰色 | `AfterSaleRuleRegistry` 未注册;`getRule.allow=false` |
|
||||
| 路由 404 | 插件未启用;`route/admin.php` 前缀与菜单不一致 |
|
||||
|
||||
---
|
||||
|
||||
## 18. 与 trade / marketing 协作边界(小结)
|
||||
|
||||
```text
|
||||
product Handler → SKU、库存、履约、产品售后、详情 sections
|
||||
trade Handler → activityId、活动价、validateOrder、活动生命周期
|
||||
marketing 监听 → OrderPaySuccess 等发奖,不改 order_item 成交价
|
||||
```
|
||||
|
||||
新增「课程商品 + 课程限时价」:product 模块管内容与开通,trade 模块管限时价与资格,两者通过 `canCombineWith` 与 trade 校验联动,**不要在 product Handler 里写秒杀 SQL**。
|
||||
@ -0,0 +1,303 @@
|
||||
# 业务模块分册 · 交易模式(trade)
|
||||
|
||||
核对日期:2026-11-06(按 `backend/modules/trade/` 现役代码核对)。代码与本文冲突时以代码为准。
|
||||
|
||||
**先读**:[00-总览-类型路由与边界](00-总览-类型路由与边界.md) 第 4 节;领域判定:[domain-selection.md](../domain-selection.md);注册契约:[registration-and-contracts.md](../registration-and-contracts.md)。
|
||||
|
||||
强制红线:[插件开发规范](../../../../rules/插件开发规范.md)、[订单中台规范](../../../../rules/订单中台规范.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 领域判定:口诀与反例
|
||||
|
||||
### 1.1 口诀
|
||||
|
||||
**改变「怎么卖」**——在已有 product 形态上,改变活动价、购买资格、限购/活动库存、拼团/砍价/秒杀生命周期;且**校验失败必须阻断下单** → `backend/modules/trade/{mode}/`,`App::register()` 登记 `TradeModeRegistry`。
|
||||
|
||||
订单创建/支付主流程通过 `TradeModeRegistry::get($modeKey)` 取 Handler,**禁止**在 `app/services/order/*` 增加 `if ($mode=='seckill')`。
|
||||
|
||||
### 1.2 应归入 trade
|
||||
|
||||
| 特征 | 现役模块 |
|
||||
| --- | --- |
|
||||
| 活动价覆盖 SKU 价 | `trade/seckill` |
|
||||
| 成团/砍价进度、失败退款 | `trade/combination`、`trade/bargain` |
|
||||
| 积分+现金混合、独立订单语义 | `trade/integral`(另注册 `integral_order` Adapter) |
|
||||
| `validateOrder` / `beforeCreateOrder` 抛错即不可买 | 所有 ActivityHandler |
|
||||
|
||||
### 1.3 反例
|
||||
|
||||
| 误判 | 正确落位 |
|
||||
| --- | --- |
|
||||
| 支付成功送优惠券 | `marketing` |
|
||||
| 新商品是「票务」形态、座位库存 | `product`(trade 只做活动价) |
|
||||
| 仅后台统计、不参与下单计价 | `marketing` 或报表模块 |
|
||||
| 去掉后用户仍能以活动价成交 | 说明计价逻辑还留在 app,应收回 trade Handler |
|
||||
|
||||
### 1.4 trade 与 marketing 分界
|
||||
|
||||
- 订单管线**必须**调用 Handler 完成交易校验或计价 → trade。
|
||||
- 去掉功能后订单创建/支付语义不变,只是不再发奖 → marketing。
|
||||
- `isMarketingActivity(): true` 的模式会进入 `getMarketingKeys()`,用于商品列表活动角标;**普通购买**由 app 层 `DefaultTradeModeHandler` 注册为 `normal`,且 `isMarketingActivity()` 为 false(详见 2026-10-30-02 文)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现役模块对照
|
||||
|
||||
| 目录 | plugin key | modeKey | 独立 OrderAdapter | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `trade/seckill` | `activity_seckill` | `seckill` | 否(复用商品订单) | 活动价、时段、库存 |
|
||||
| `trade/combination` | `activity_combination` | `combination` | 否 | 拼团生命周期、事件多 |
|
||||
| `trade/bargain` | `activity_bargain` | `bargain` | 否 | 砍价进度 |
|
||||
| `trade/integral` | `activity_integral` | `integral` | **是** `integral_order` | 积分商城 |
|
||||
|
||||
清单约定:
|
||||
|
||||
- `type`: `mode`(不是 `trade` 字符串)。
|
||||
- `key`: 现役多用 `activity_{name}`;新模块可继续此风格以对齐菜单。
|
||||
- `path`: `modules/trade/{name}`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐参考模块
|
||||
|
||||
| 场景 | 复制 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 活动价 + 下单校验 + 活动 CRUD | `trade/seckill` | Handler 最标准,`SeckillActivityHandler` 清晰 |
|
||||
| 复杂生命周期 + 多事件 | `trade/combination` | 过期、成团、退款联动 |
|
||||
| 需要独立订单表/状态 | `trade/integral` | `App::boot` 里 `OrderTypeRegistry::register` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录树
|
||||
|
||||
```text
|
||||
backend/modules/trade/{mode}/
|
||||
├── plugin.json # type=mode, key=activity_{mode}
|
||||
├── App.php # register → TradeModeRegistry
|
||||
├── Installer.php
|
||||
├── handler/{Mode}ActivityHandler.php # extends BaseTradeModeHandler
|
||||
├── event.php # 活动生命周期、OrderPay* 等
|
||||
├── controller/admin|user/
|
||||
├── route/admin.php | user.php
|
||||
├── service/ dao/ model/ validate/
|
||||
├── listener/ jobs/ crontab/
|
||||
└── frontend/admin/.../plugin/activity_{mode}/
|
||||
|
||||
integral 额外:
|
||||
└── handler/IntegralOrderAdapter.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. plugin.json 模板
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "activity_yourmode",
|
||||
"name": "你的活动",
|
||||
"description": "交易模式:活动价与下单校验",
|
||||
"version": "1.0.0",
|
||||
"author": "作者",
|
||||
"type": "mode",
|
||||
"path": "modules/trade/yourmode",
|
||||
"require": { "crmeb": "^8.0", "php": "^8.0" }
|
||||
}
|
||||
```
|
||||
|
||||
可选 `diy.components`(如秒杀列表组件名与 DIY 数据节点一致)。
|
||||
|
||||
---
|
||||
|
||||
## 6. App.php
|
||||
|
||||
### 6.1 标准(复用商品订单)
|
||||
|
||||
```php
|
||||
public static function register(\think\App $app): void
|
||||
{
|
||||
$app->make(TradeModeRegistry::class)
|
||||
->register('seckill', SeckillActivityHandler::class);
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 独立订单类型(integral 模式)
|
||||
|
||||
`trade/integral/App.php` 真源:
|
||||
|
||||
- `register()`:只注册 `TradeModeRegistry`。
|
||||
- `boot()`:`OrderTypeRegistry::register('integral_order', IntegralOrderAdapter::class)` + `loadEvent`。
|
||||
|
||||
**规则**:复用普通商品订单的活动(秒杀/拼团)**不要**虚构新 `source_type`;只有订单语义与 normal 完全不同(积分单、0 元纯积分)才注册 Adapter。
|
||||
|
||||
### 6.3 启动顺序(理解即可)
|
||||
|
||||
`AppService::boot()` 先 `PluginManager` 触发各模块 `register`,再注册 app 内置 `normal` / `member`。不要试图在模块里覆盖 `normal` key。
|
||||
|
||||
---
|
||||
|
||||
## 7. TradeModeHandler 契约(方法级)
|
||||
|
||||
接口:`backend/crmeb/trade/mode/TradeModeHandlerInterface.php`(29 方法)。基类:`BaseTradeModeHandler`。
|
||||
|
||||
### 7.1 身份与展示
|
||||
|
||||
| 方法 | 说明 |
|
||||
| --- | --- |
|
||||
| `getType(): string` | modeKey,如 `seckill` |
|
||||
| `getLabel(): string` | 中文标签,供 `allInfo()` 下拉 |
|
||||
| `getLegacyType(): int` | 旧版数字 type 映射,`getByLegacyType()` 用 |
|
||||
| `isMarketingActivity(): bool` | true → 进入 `getMarketingKeys()` |
|
||||
|
||||
### 7.2 商品与活动查询
|
||||
|
||||
| 方法 | 说明 |
|
||||
| --- | --- |
|
||||
| `getProductExist(array $productIds): array` | 各商品是否参与本模式 |
|
||||
| `isProductInActivity(int $productId): bool` | 单商品 |
|
||||
| `getProductActivity(int $productId): ?array` | 活动摘要(列表角标) |
|
||||
| `getActivityIdsList(...): array` | 批量活动 ID |
|
||||
|
||||
### 7.3 计价与校验(核心)
|
||||
|
||||
| 方法 | 说明 |
|
||||
| --- | --- |
|
||||
| `getActivityPrice(int $productId, string $skuUnique, int $activityId): array` | 活动价;返回结构对齐 seckill |
|
||||
| `validateOrder(...): void` | **失败抛异常**,阻断下单 |
|
||||
| `checkCartStock(...): array` | 购物车数量校验 |
|
||||
| `beforeCreateOrder(...): void` | 创建前最后闸门 |
|
||||
| `getOrderExtraInfo(int $activityId): array` | 写入订单扩展 |
|
||||
| `getAllowedPayTypes` / `isFreePay` / `getFreePayType` / `getPayRedirectUrl` | 支付限制 |
|
||||
|
||||
### 7.4 订单生命周期钩子
|
||||
|
||||
| 方法 | 典型用途 |
|
||||
| --- | --- |
|
||||
| `afterOrderCreate` | 占活动库存、开团 |
|
||||
| `beforeOrderPay` / `onOrderPay` / `afterOrderPay` | 支付成功扣活动量、成团 |
|
||||
| `onOrderPayFail` | 释放占用 |
|
||||
| `beforeOrderCancel` / `afterOrderCancel` | 回滚活动库存 |
|
||||
| `beforeOrderRefund` / `afterOrderRefund` | 活动侧回滚 |
|
||||
| `onOrderComplete` | 确认收货后活动完结 |
|
||||
| `getOrderDetailExtend` / `getActivityButtons` | 详情展示与按钮 |
|
||||
|
||||
未支持的能力保持基类默认;**可选展示失败可降级,校验失败不可降级**(registration-and-contracts.md)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与 product 的协作
|
||||
|
||||
- 下单前:中台先解析 `product_type` → Product Handler 取 SKU,再解析 `trade_mode` + `activity_id` → Trade Handler 计价与 `validateOrder`。
|
||||
- Product Handler 的 `canCombineWith($tradeMode)` 必须声明是否允许该模式;trade 侧仍要做活动库存校验。
|
||||
- 不要在 trade Handler 里改商品扩展表结构;活动数据放 trade 自有表(如 `store_seckill`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. event.php
|
||||
|
||||
trade 模块常用事件(以 combination/seckill 为准,新增模式按需挂载):
|
||||
|
||||
- 订单支付/取消/退款相关(与 Handler 钩子互补,适合通知、队列)。
|
||||
- 活动过期:`crontab` + 监听器更新活动状态。
|
||||
|
||||
事件 key 为**字符串**(无 `Listener` 后缀),与 `app/event.php` 触发名一致。见 [事件规范](../../../../rules/事件规范.md)。
|
||||
|
||||
---
|
||||
|
||||
## 10. Installer
|
||||
|
||||
- 活动表、参与记录表:`runMigrate()` 幂等 DDL。
|
||||
- 菜单挂 `core:admin-marketing` 或现役 seckill 同级父级。
|
||||
- **定时任务**:过期关闭、自动取消未付款团单
|
||||
- `App::register` → `CrontabTaskRegistry::register($mark, $class)`
|
||||
- `seed()` 写 `system_crontab`(`mark` 一致、`is_open=1`)
|
||||
- `cleanData()` 按 mark 删除
|
||||
- 参考 `marketing/signin` 与 `product/normal` 的 `NormalOrderCancelTask` 接管说明(registration-and-contracts.md 定时任务节)
|
||||
|
||||
---
|
||||
|
||||
## 11. 路由与前后端
|
||||
|
||||
- Admin 路由组名常与 mode 相关,如 `/adminapi/seckill/...`。
|
||||
- 用户端:活动列表、详情、开团/参团 API;参数含 `activity_id` / `time_id` 等与 Handler 一致。
|
||||
- 前端 uni-app:优先用接口返回的活动价与按钮,核心下单页继续按 `trade_mode` 的既有数据渲染;新交互优先扩展 `page_config`。确需独立活动页时放 `pages-modules/<标识>/`,用法见 [uniapp-pages.md](../uniapp-pages.md)。
|
||||
|
||||
管理端 plugin 目录:`frontend/admin/src/pages/plugin/activity_{mode}/`。
|
||||
|
||||
---
|
||||
|
||||
## 12. 逐步落地
|
||||
|
||||
1. 定 **modeKey**(英文小写)、**plugin key**(`activity_*`)、是否与 normal 共用订单。
|
||||
2. 复制 **seckill** 骨架,改 Handler 类名与表结构。
|
||||
3. 设计活动表 + 参与/库存表,`Installer` 建表。
|
||||
4. 实现 Handler:`getType/getLabel/isMarketingActivity` → `getProductExist` → **`getActivityPrice` + `validateOrder`**。
|
||||
5. `App::register` 注册;`registry:check` 通过。
|
||||
6. 实现活动 CRUD 与用户端参与 API。
|
||||
7. 覆盖支付/取消/退款钩子(至少与库存/名额一致)。
|
||||
8. 过期 **crontab**(若活动有时间窗)。
|
||||
9. `event.php` 补监听(通知、异步)。
|
||||
10. 管理端页面 + DIY 链接 seed。
|
||||
11. 全链路验收。
|
||||
12. 打包 `.plugin-packages/`。
|
||||
|
||||
---
|
||||
|
||||
## 13. 全链路验收
|
||||
|
||||
### 13.1 静态
|
||||
|
||||
```bash
|
||||
cd backend && php think plugin:manifest-check && php think registry:check
|
||||
```
|
||||
|
||||
trade 专属:`registry:check` 应看到 modeKey 已注册;integral 还应看到 `integral_order` Adapter。
|
||||
|
||||
### 13.2 活动 + 订单
|
||||
|
||||
- [ ] 创建活动并绑定商品;列表/详情展示活动价
|
||||
- [ ] 未满足资格时 `validateOrder` 阻断(前端收到明确错误)
|
||||
- [ ] 下单金额与 `getActivityPrice` 一致;支付回调后活动库存一致
|
||||
- [ ] 取消/超时释放占用;退款后活动侧回滚
|
||||
- [ ] 商品列表活动标签仅出现在 `getMarketingKeys()` 包含的模式
|
||||
- [ ] 与 product `canCombineWith` 冲突时被拒绝
|
||||
|
||||
### 13.3 生命周期
|
||||
|
||||
- [ ] 安装/启用/禁用/卸载(隔离库);禁用后不能以该 mode 下单
|
||||
|
||||
---
|
||||
|
||||
## 14. 打包与交付
|
||||
|
||||
只打包 `backend/modules/trade/{mode}/` 与对应 admin plugin 前端;zip 至 `.plugin-packages/`,文件名含 key 与 version。不改核心升级包。
|
||||
|
||||
---
|
||||
|
||||
## 15. 禁止事项
|
||||
|
||||
- 禁止在 `OrderCenterServices` 等中央 Service 写 mode 分支。
|
||||
- 禁止把纯发奖逻辑注册进 `TradeModeRegistry`(应用 marketing 事件)。
|
||||
- 禁止为「只是打折」虚构独立 `source_type`(除非 integral 类独立订单)。
|
||||
- 禁止 trade 模块 use 其他 trade/product 模块类;共享逻辑上收 crmeb 或事件。
|
||||
- 禁止校验失败仍返回成功或静默改价(安全与对账风险)。
|
||||
|
||||
---
|
||||
|
||||
## 16. 排错速查
|
||||
|
||||
| 现象 | 查 |
|
||||
| --- | --- |
|
||||
| 活动价未生效 | 下单参数是否带 `activity_id`;`getActivityPrice` 是否被调用 |
|
||||
| 支付后活动库存未扣 | `onOrderPay` / `afterOrderPay`;事务是否回滚 |
|
||||
| 所有商品都显示活动标 | 是否误用 Default 的 `getProductExist`;应用 `getMarketingKeys()` 过滤 |
|
||||
| mode 未注册 | 插件未启用;`App::register` 未执行 |
|
||||
| 旧接口 type=1 无效 | 实现 `getLegacyType()` 与 `getByLegacyType` 映射 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 阅读延伸
|
||||
|
||||
- 注册中心行为:`backend/crmeb/trade/mode/TradeModeRegistry.php`
|
||||
- 内置 normal/member:`backend/app/AppService.php` boot 段
|
||||
- 订单如何取 Handler:在 `OrderCenterServices` / 下单 Service 中搜 `TradeModeRegistry`
|
||||
@ -110,7 +110,7 @@ protected function cleanData(): int
|
||||
|
||||
约束:
|
||||
|
||||
- 页面路径必须与 `frontend/uni-app/src/pages.json` 实际注册一致(含分包,如积分商城 `pages/points_mall`);详情页 `param` 以页面 `onLoad` 实际读取的参数为准,不要照抄旧版字典数据。
|
||||
- 页面路径必须是端上真实可打开的路径。已经写在 `frontend/uni-app/src/pages.json` 里的存量页面(如积分商城 `pages/points_mall`、秒杀 `/pages/activity/goods_seckill/index`)保持原路径。新建插件页的路径来自 `pages-modules` 扫描结果,形如 `/pages-modules/<标识>/index`,磁盘上的 `pages.json` 不含这些路径;放入后的跳转与装修用法见 [uniapp-pages.md](uniapp-pages.md)。详情页 `param` 以页面 `onLoad` 实际读取的参数为准。
|
||||
- 分类命名沿用现有惯例(`秒杀链接`/`砍价链接`/`分销链接` 等),挂在共享父分类"营销链接"下;分类名即清理边界,不要使用与其他插件可能冲突的名称。
|
||||
- `seedPageLinks()` 按 url 全局查重:旧站点预置在共享分类(如"个人中心链接")下的插件链接不会重复插入,因此这类链接直接写在插件自己的链接配置里即可(如砍价记录、我的推广),卸载时同样按 url 清理。
|
||||
- `cleanPageLinks()` 按 url 白名单删除链接;独有分类仅在分类下已无链接时删除,避免误删商家在插件分类下自建的链接。
|
||||
|
||||
202
.crmeb/skills/crmeb-plugin-dev/references/uniapp-pages.md
Normal file
202
.crmeb/skills/crmeb-plugin-dev/references/uniapp-pages.md
Normal file
@ -0,0 +1,202 @@
|
||||
# 插件用户端页面:放入 pages-modules 之后怎么用
|
||||
|
||||
把目录放进 `frontend/uni-app/src/pages-modules/<标识>/` 并重新编译客户端之后,页面地址是 `/pages-modules/<标识>/...`。后期从代码、装修、消息里打开它,都用该模块 `routes.js` 里的那条路径。
|
||||
|
||||
适用两类事:新插件要独立的 H5 / 小程序 / APP 页面;页面已经在 `pages-modules` 里,后续要跳转、让商家选用、或带参数打开。
|
||||
|
||||
订单和商品详情优先走已有的 `page_config`、`display_sections`(见 [SKILL.md](../SKILL.md) 开发流程第 10 步)。独立活动页才新建这里的页面。
|
||||
|
||||
扫描如何合并进路由、磁盘上的 `pages.json` 为何保持不动,见 [uni-app 端约定](../../../scopes/uni-app.md)「pages-modules 自动分包注册」。目录与文件名见 [命名规范](../../../../frontend/uni-app/docs/命名规范.md) §2.1。插件不得改移动端核心页面、核心 `api` 与 `src/pages.json`,见 [插件开发规范](../../../rules/插件开发规范.md)。本文只写放入之后怎么用。
|
||||
|
||||
## 1. 放入后的目录
|
||||
|
||||
复制 `frontend/uni-app/src/pages-modules/_template/`,粘成 `src/pages-modules/<标识>/`。目录名用后端模块目录的最后一段:`backend/modules/marketing/lottery` 对应 `lottery`,与命名规范 §2.1 的对照表一致。清单 key(如 `activity_lottery`)留在 `plugin.json` 和 Installer,不作为这个目录名。
|
||||
|
||||
下文用 `example` 演示路径形状。仓库里目前没有这个目录,正式名字仍按命名规范对照表取。
|
||||
|
||||
```text
|
||||
src/pages-modules/example/
|
||||
├── index.vue 模块首页
|
||||
├── routes.js 本模块全部路径,后期跳转只读这里
|
||||
├── api.js 可选,本模块接口
|
||||
├── pages.json 可选,标题与导航样式
|
||||
├── record/index.vue 子页面
|
||||
├── components/PrizeCard.vue 局部组件,不成为页面
|
||||
└── static/ 图片,不成为页面
|
||||
```
|
||||
|
||||
`_template` 以及任何 `_` 开头的目录不参与编译。页面文件是 snake_case 目录加 `index.vue`。局部组件只放 `components/`,文件名 PascalCase,并在页面里 `import`。easycom 只自动扫描 `src/components/`,扫不到这里。`components/`、`component/`、`static/` 里的 `.vue` 不会被注册成页面。
|
||||
|
||||
把 `record.vue` 直接放在模块里会触发 `npm run lint:naming` 报错。新页面写成 `record/index.vue`。
|
||||
|
||||
## 2. 页面地址
|
||||
|
||||
| 文件 | 打开地址 |
|
||||
| --- | --- |
|
||||
| `index.vue` | `/pages-modules/example/index` |
|
||||
| `record/index.vue` | `/pages-modules/example/record/index` |
|
||||
|
||||
地址以 `/` 开头,包含 `pages-modules` 和模块名。查询参数接在后面:`/pages-modules/example/record/index?id=1`。
|
||||
|
||||
`routes.js` 里的 `MODULE_NAME` 与目录名相同。模板默认是 `_template`,复制后必须改掉,否则常量会指向不会被编译的 `_template` 目录。每增加一个页面,加一条常量。模板见 `frontend/uni-app/src/pages-modules/_template/routes.js`。
|
||||
|
||||
```js
|
||||
const MODULE_NAME = 'example';
|
||||
|
||||
export default {
|
||||
index: `/pages-modules/${MODULE_NAME}/index`,
|
||||
record: `/pages-modules/${MODULE_NAME}/record/index`,
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 后期从代码打开
|
||||
|
||||
模块根上的 `index.vue` 与 `routes.js` 同级:
|
||||
|
||||
```js
|
||||
import routes from './routes.js';
|
||||
|
||||
uni.navigateTo({ url: routes.record + '?id=' + id });
|
||||
```
|
||||
|
||||
子页面 `record/index.vue` 向上一级引用,组件同样显式引入:
|
||||
|
||||
```js
|
||||
import routes from '../routes.js';
|
||||
import PrizeCard from '../components/PrizeCard.vue';
|
||||
```
|
||||
|
||||
模块外部(其他页面、DIY 组件)用 `@/` 别名,`@` 指向 `src/`:
|
||||
|
||||
```js
|
||||
import exampleRoutes from '@/pages-modules/example/routes.js';
|
||||
|
||||
uni.navigateTo({ url: exampleRoutes.index });
|
||||
```
|
||||
|
||||
页面用 `onLoad` 读取参数,参数名与装修链接里的 `param` 一致:
|
||||
|
||||
```js
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
|
||||
onLoad((options) => {
|
||||
const id = options.id;
|
||||
});
|
||||
```
|
||||
|
||||
订单中台页面继续用 `frontend/uni-app/src/dynamic-pages/api/routes.js`。插件页的常量留在各模块自己的 `routes.js`。
|
||||
|
||||
插件页用 `uni.navigateTo` 打开。底部 tab 仍只指向主包里已有的 tab 页。
|
||||
|
||||
## 4. 后期从装修、消息里打开
|
||||
|
||||
商家在后台装修的链接选择器里选用。Installer 把同一条路径写入链接表。分类、查重和卸载清理见 [manifest-and-lifecycle.md](manifest-and-lifecycle.md)「DIY 页面链接」。
|
||||
|
||||
新页面的 `url` 与 `routes.js` 逐字相同:
|
||||
|
||||
```php
|
||||
$cateId = $this->ensurePageLinkCategory(['商城页面', '营销链接', '示例链接']);
|
||||
$count += $this->seedPageLinks($cateId, [
|
||||
['name' => '示例首页', 'url' => '/pages-modules/example/index'],
|
||||
[
|
||||
'name' => '示例记录',
|
||||
'url' => '/pages-modules/example/record/index',
|
||||
'param' => 'id',
|
||||
'example' => '/pages-modules/example/record/index?id=1',
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
`cleanData()` 里用同一组 url 调用 `cleanPageLinks(['示例链接'], [...])`。分类名用本插件自己的名字。`抽奖链接`、`秒杀链接` 已经由现役模块占用。
|
||||
|
||||
订阅消息、分享、支付回跳要打开插件页时,下发同一条路径。路径由插件自己的代码或种子数据产生。
|
||||
|
||||
已经在线上的页面保持原路径,不要改成 `/pages-modules/...`,也不要给原路径再加 `/index`:
|
||||
|
||||
| 页面 | 现役地址 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| 抽奖主页 | `/pages/goods/lottery/grids/index` | `pages.json` 分包 root `pages/goods`,页面 `lottery/grids/index`;`backend/modules/marketing/lottery/Installer.php` |
|
||||
| 中奖记录 | `/pages/goods/lottery/grids/record` | 页面文件是 `grids/record.vue`,地址没有 `/index` |
|
||||
| 秒杀列表 | `/pages/activity/goods_seckill/index` | 分包 root `pages/activity`;`backend/modules/trade/seckill/Installer.php` |
|
||||
|
||||
## 5. 标题与导航
|
||||
|
||||
模块根没有 `pages.json` 时,扫描到的页面 `style` 为空,跟随全局导航样式。
|
||||
|
||||
需要标题、下拉刷新或自定义导航时,在模块根新增 `pages.json`(允许注释):
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{ "path": "index", "style": { "navigationBarTitleText": "示例" } },
|
||||
{
|
||||
"path": "record/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "示例记录",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`path` 相对模块根,写成 `index`、`record/index`。不写 `pages-modules/example/` 前缀,不带 `.vue`。片段允许注释。模块根一旦有这份片段,该模块只注册片段里列出的页面;没写进去的 `.vue` 不会成为路由。
|
||||
|
||||
前缀写进 `path` 时,扫描仍会打印「合并自检:通过」,但地址会变成 `/pages-modules/example/pages-modules/example/index`,页面打不开。打印结果里出现两段 `pages-modules/` 时,把片段 `path` 改回模块内相对路径。
|
||||
|
||||
## 6. 页面里请求接口
|
||||
|
||||
接口放在模块内 `api.js`,调用 `@/utils/request.js`。传入的路径不含 `/api/`,封装会拼上 `/api/`。需要登录的接口不传 `noAuth: true`。路径字符串必须是本插件 `route/user.php` 里已经注册的路由,下面的 `v2/example/info/` 只说明写法,仓库里没有这条接口。
|
||||
|
||||
```js
|
||||
import request from '@/utils/request.js';
|
||||
|
||||
export function getExampleInfo(id) {
|
||||
return request.get('v2/example/info/' + id);
|
||||
}
|
||||
```
|
||||
|
||||
首页用 `import { getExampleInfo } from './api.js'`。`record/index.vue` 用 `import { getExampleInfo } from '../api.js'`。
|
||||
|
||||
现役抽奖接口在 `backend/modules/marketing/lottery/route/user.php`,分组是 `v2`,详情为 `lottery/info/:factor/[:lottery_id]`,并且走登录中间件。调用时写成 `v2/lottery/info/${factor}` 或再接活动 id,不传 `noAuth`。响应体见 [接口与响应规范](../../../rules/接口与响应规范.md)。
|
||||
|
||||
## 7. 放进去之后怎么确认
|
||||
|
||||
页面随客户端源码一起编译。后台安装插件不会把页面送进已经发布的 H5 或小程序;只上传后端插件包时,端上还没有这个目录。后台卸载会按 url 清理链接选择器中的条目。已经保存进装修数据的跳转地址仍是原来的字符串。已发布的小程序要等下一次发版,才会带上或去掉这些页面。
|
||||
|
||||
在 `frontend/uni-app` 目录执行:
|
||||
|
||||
```bash
|
||||
node scripts/uni-auto-subpackages.mjs
|
||||
npm run lint:naming
|
||||
```
|
||||
|
||||
只有 `_template` 时,脚本打印「未发现可注册模块」,合并自检仍然通过。放入 `example` 且含首页和 `record/index.vue`、又没有模块根 `pages.json` 时,输出为:
|
||||
|
||||
```text
|
||||
▸ pages-modules/example(2 页)
|
||||
/pages-modules/example/index
|
||||
/pages-modules/example/record/index
|
||||
合并自检:通过(subPackages 共 N 个分包)
|
||||
```
|
||||
|
||||
末尾的 N 包含系统原有分包。「合并自检:通过」只表示 JSON 能合并。还要核对打印出的地址与 `routes.js`、Installer 的 `url` 三者一致,再把链接写入种子。
|
||||
|
||||
H5 用 `npm run dev:h5`,或在仓库根执行 `./run.sh uni:dev`。H5 路由是 history、base 为空,浏览器打开 `http://localhost:5173/pages-modules/example/index`(端口以终端输出为准)。
|
||||
|
||||
微信小程序把开发者工具导入 `dist/dev/mp-weixin`,编译模式的启动页填 `pages-modules/example/index`(这条不带前导 `/`)。
|
||||
|
||||
dev 已经在跑时,增删 `.vue` 会在约 15 秒内触发重编。列表里看不到新页面时,重启一次 dev。日常改页面不必执行 `build:*`。交付小程序包时执行 `npm run build:mp-weixin`,确认 `frontend/uni-app/dist/build/mp-weixin/app.json` 的 `subPackages` 含 `pages-modules/example`。
|
||||
|
||||
## 8. 后期使用对照
|
||||
|
||||
| 要做的事 | 用什么 |
|
||||
| --- | --- |
|
||||
| 从别的页面打开 | `import` 该模块 `routes.js`,再 `uni.navigateTo` |
|
||||
| 装修里让商家选用 | `seedPageLinks` 的 `url` 等于 `routes.js` 的值 |
|
||||
| 带商品或活动 id | query 接在路径后;页面 `onLoad(options)` 读取;`param` 写同样的参数名 |
|
||||
| 改标题、下拉刷新 | 模块根 `pages.json` 片段 |
|
||||
| 调本插件接口 | 模块内 `api.js`,后端用插件 `route/user.php` |
|
||||
| 确认路径已注册 | `node scripts/uni-auto-subpackages.mjs` |
|
||||
|
||||
每个模块是一个分包。微信小程序单包 2MB、总包 30MB 的限制同样适用。
|
||||
@ -37,6 +37,7 @@ description: 开发和重构 CRMEB v8 项目基础框架、ThinkPHP 后端应用
|
||||
- 普通业务数据库访问必须经 DAO/Model;直接调用 `Db::table()`/`Db::name()` 仅限 Installer、迁移和框架生命周期代码 —— [.crmeb/rules/代码规范.md](../../rules/代码规范.md) 第三节
|
||||
- 后端事件使用业务字符串 key,例如 `event('OrderPaySuccess', [...])`;不得使用监听器类名或类常量 —— [.crmeb/rules/事件规范.md](../../rules/事件规范.md) 2.1
|
||||
- 控制器类名和文件名以 `Controller` 结尾;控制器方法使用 camelCase —— [.crmeb/rules/代码规范.md](../../rules/代码规范.md) 〇 / 一
|
||||
- 防过度防御、入口单次校验、拒绝提前抽象、控制复杂度 —— [.crmeb/rules/编码克制规范.md](../../rules/编码克制规范.md)
|
||||
- 新增 PHP 类及 public/protected 方法遵循项目的中文 PHPDoc 规范 —— [.crmeb/rules/架构规范.md](../../rules/架构规范.md)「代码注释规范」
|
||||
- 除非任务明确要求,不得修改 `backend/.env`、`backend/.constant`、凭据、生成的 `runtime/`、前端构建产物或锁文件 —— [.crmeb/rules/提交规范.md](../../rules/提交规范.md) 禁止入库清单
|
||||
|
||||
|
||||
@ -6,10 +6,10 @@
|
||||
|
||||
| 区域 | 当前实现 |
|
||||
|---|---|
|
||||
| 后端 | PHP 8.2-8.4、ThinkPHP 8、MySQL 5.7+(推荐 8.0)、Redis |
|
||||
| 后端 | PHP 8.2-8.4(默认 8.4)、ThinkPHP 8、MySQL 5.7+(推荐 8.0)、Redis |
|
||||
| Admin | Vue 3.5、Vue Router 4、Pinia 2、Element Plus 2.9、Vite 5 |
|
||||
| 用户端 | uni-app CLI(Vite 5)工程,Vue 3 + `<script setup>`、Pinia 2、Sass |
|
||||
| 本地默认运行环境 | 宿主机 PHP 8.2-8.4 + MySQL 5.7+(推荐 8.0)+ Redis;HTTP 端口 `8011`,数据服务按 `backend/.env` 实际配置 |
|
||||
| 本地默认运行环境 | 宿主机 PHP 8.2-8.4(默认 8.4)+ MySQL 5.7+(推荐 8.0)+ Redis;HTTP 端口 `8011`,数据服务按 `backend/.env` 实际配置 |
|
||||
| 可选容器环境 | Docker Compose:Nginx `8011`、MySQL `33061`、Redis `63791` |
|
||||
|
||||
版本信息以 `backend/composer.json`、`frontend/admin/package.json`、`frontend/uni-app/package.json` 和当前代码为准。管理端与用户端均已完成 Vue 3 迁移,README 中残留的 Vue 2 / Vuex / element-ui / ThinkPHP 6 描述属于过期资料。
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: crmeb-uniapp-dev
|
||||
description: 开发和修改 frontend/uni-app 下的 CRMEB 移动端(uni-app 官方 CLI + Vue 3 + script setup + Pinia + Sass),包括 H5/微信小程序/支付宝小程序/APP 页面、API 封装、条件编译、分包及后端驱动的订单/商品动态渲染。适用于用户侧多端客户端任务,不用于管理端或后端。
|
||||
description: 开发和修改 frontend/uni-app 下的 CRMEB 移动端(uni-app 官方 CLI + Vue 3 + script setup + Pinia + Sass),包括 H5/微信小程序/支付宝小程序/APP 页面、API 封装、条件编译、分包、pages-modules 插件页放入后的跳转,及后端驱动的订单/商品动态渲染。适用于用户侧多端客户端任务,不用于管理端或后端。
|
||||
---
|
||||
|
||||
# CRMEB Uni-App 开发
|
||||
@ -23,7 +23,7 @@ Node >= 18;全部 `@dcloudio/*` 锁定同一版本,升级必须整体同步
|
||||
## 确定改动位置
|
||||
|
||||
- 普通页面:在 `src/pages/` 下创建或修改页面,在 `src/pages.json` 注册,并放入合适的分包(主包只保留 tab/guide 页)。
|
||||
- 独立业务模块页面:复制 `src/pages-modules/_template/` 为 `<snake_case 模块名>/` 即自动注册为分包,免改 `src/pages.json`;约定与边界见 `frontend/uni-app/AGENTS.md`「pages-modules 自动分包注册」。
|
||||
- 独立业务模块页面:复制 `src/pages-modules/_template/` 为 `<snake_case 模块名>/` 即自动注册为分包。放入之后的地址、`routes.js` 跳转、装修链接与验收见 [uniapp-pages.md](../crmeb-plugin-dev/references/uniapp-pages.md);扫描机制见 [uni-app 端约定](../../scopes/uni-app.md)「pages-modules 自动分包注册」。
|
||||
- 共享行为:优先复用相近组件、composables、`utils/`、`libs/`,不要把平台逻辑复制到多个页面。
|
||||
- HTTP 接口:在 `src/api/` 下添加领域函数并调用 `@/utils/request.js`;路径不要带 `/api/`,封装层会自动添加。
|
||||
- 订单、下单、支付、售后:走 `src/dynamic-pages/` 引擎与契约,阅读 [references/dynamic-pages.md](references/dynamic-pages.md)。
|
||||
@ -36,7 +36,7 @@ Node >= 18;全部 `@dcloudio/*` 锁定同一版本,升级必须整体同步
|
||||
2. 新代码用 Vue 3 `<script setup>`:页面生命周期(`onLoad/onShow/onReachBottom` 等)从 `@dcloudio/uni-app` 引入,组件生命周期(`onMounted/onBeforeUnmount`)从 `vue` 引入。
|
||||
3. 状态用 Pinia(`useAppStore()` 等 + `storeToRefs`),禁止新增 Vuex;主题色/验证码/海报用 `composables/`(`useColorTheme` / `useSendVerifyCode` / `useSharePoster`),禁止新增 mixin。
|
||||
4. 导航、请求、存储、上传、权限和反馈统一使用 `uni.*` API;多端差异用 `#ifdef H5 / MP-WEIXIN / APP-PLUS` 条件编译保护。
|
||||
5. 订单相关跳转一律引用 `dynamic-pages/api/routes.js` 的常量;其他新页面必须注册到 `pages.json`,并使用准确路径导航。
|
||||
5. 订单相关跳转引用 `dynamic-pages/api/routes.js`。插件独立页放 `pages-modules/`,跳转引用该模块 `routes.js`(见 [uniapp-pages.md](../crmeb-plugin-dev/references/uniapp-pages.md))。其余核心新页面写入 `src/pages.json`。
|
||||
6. 后端下发的 `page_config` 区块、按钮、状态文案和扩展数据视为契约;模块可扩展的业务类型不得新增前端硬编码映射。
|
||||
7. 提供适合触屏和小屏幕的加载、空数据、重试、错误和禁止重复提交状态。
|
||||
|
||||
|
||||
@ -50,10 +50,10 @@ request.get('public/resource', params, { noAuth: true });
|
||||
|
||||
## 页面与导航
|
||||
|
||||
- 每个页面都必须在 `src/pages.json` 的主包 `pages` 或某个 `subPackages[].pages` 中声明。
|
||||
- 核心页面在 `src/pages.json` 的主包 `pages` 或某个 `subPackages[].pages` 中声明。插件独立页放 `src/pages-modules/<标识>/`,由扫描器注册,用法见 [uniapp-pages.md](../../crmeb-plugin-dev/references/uniapp-pages.md)。
|
||||
- 大型流程放入合适的现有分包,不要无必要扩大主包。
|
||||
- tab bar 页面使用 `uni.switchTab`,普通页面栈导航使用 `uni.navigateTo`;只有确实需要相应历史行为时才用 `redirectTo` 或 `reLaunch`。
|
||||
- 已使用 `dynamic-pages/api/routes.js` 的核心流程继续在其中统一维护路由,不得散落硬编码路径。
|
||||
- 订单中台跳转继续用 `dynamic-pages/api/routes.js`。插件页跳转用该模块自己的 `routes.js`。
|
||||
- 页面生命周期从 `@dcloudio/uni-app` 引入:`onLoad(options)` 读参数,`onShow` 刷新可能变化的动态数据。
|
||||
- 筛选条件变化时同时重置分页,并用 `loading`、`finished` 防止触底重复请求。
|
||||
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -1,5 +1,8 @@
|
||||
# 本文件只影响未跟踪文件;已有跟踪项不会因新增规则自动退出仓库。
|
||||
|
||||
# 本机私有:开源回馈工作流(不入库)
|
||||
/开源同步.md
|
||||
|
||||
# 以 "." 开头的目录全部忽略,保留仓库根目录 .crmeb/
|
||||
.*/
|
||||
!/.crmeb/
|
||||
@ -7,11 +10,11 @@
|
||||
|
||||
# backend 本地文件与目录
|
||||
/backend/tests/
|
||||
/backend/public/admin/
|
||||
/backend/public/assets/
|
||||
/backend/public/pages/
|
||||
/backend/public/static/
|
||||
/backend/public/index.html
|
||||
!/backend/public/admin/
|
||||
!/backend/public/assets/
|
||||
!/backend/public/pages/
|
||||
!/backend/public/static/
|
||||
!/backend/public/index.html
|
||||
/backend/public/theme/
|
||||
/backend/public/uploads/
|
||||
/backend/public/install.lock
|
||||
@ -37,5 +40,4 @@
|
||||
|
||||
#docker
|
||||
/docker/mysql/data/*
|
||||
/docker/mysql/log/*
|
||||
.DS_Store
|
||||
/docker/mysql/log/*
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
561
README.md
561
README.md
@ -1,289 +1,356 @@
|
||||
# CRMEB v8 电商系统
|
||||
<div align="center">
|
||||
|
||||
> 基于 ThinkPHP 8.0 的三端一体电商 CRM 系统:后端 + 管理端(Vue 3)+ 移动端(uni-app),支持 PC / H5 / 小程序 / APP。
|
||||
# CRMEB High-Quality Open-Source Mall System v8 (PHP)
|
||||
|
||||
**后端运行环境**:PHP 8.2-8.4 + MySQL 5.7+(推荐 8.0)+ Redis | **前端构建**:Node >= 18.20
|
||||
**主开发分支**:`v8.0.dev`,正式发布以 Release / Tag 为准
|
||||
**Mid-platform Architecture · Modular Extensions · AI-Native · Multi-Terminal**
|
||||
|
||||
> 本仓库提供统一的 AI 开发入口(AGENTS.md 标准)。使用 Claude Code、Cursor、Codex、Copilot 等 AI 工具开发本项目前,请让 AI 先读取 [AGENTS.md](AGENTS.md) → [.crmeb/AGENTS.md](.crmeb/AGENTS.md)。详见下文[用 AI 开发本项目](#用-ai-开发本项目)。
|
||||
[中文说明](README_ZH.md)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href='https://gitee.com/ZhongBangKeJi/CRMEB/stargazers'>
|
||||
<img src='https://gitee.com/ZhongBangKeJi/CRMEB/badge/star.svg?theme=gvp' alt='star'></img>
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/Licence-apache2.0-green.svg?style=flat" />
|
||||
<img src="https://img.shields.io/badge/Edition-8.0.0-blue.svg" />
|
||||
</div>
|
||||
|
||||
<div align="center" style="font-size: 15px;">
|
||||
Built with care for open source — a Star 🌟 in the top-right corner means a lot to us.
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 快速开始(宿主机,默认)
|
||||
### 📝 **Project Overview**
|
||||
|
||||
**Open Source Freedom**
|
||||
|
||||
CRMEB open-source mall is 100% open source under the **Apache-2.0** license, free for commercial use — truly “zero-cost” deployment and secondary development. A new mall foundation for the AI era.
|
||||
|
||||
**Mid-platform Architecture**
|
||||
|
||||
v8 is rebuilt as a mid-platform e-commerce architecture: the **Product Mid-platform** unifies multi-type products; the **Order Mid-platform** aggregates status and operations across order sources; the **After-sales & Refund Mid-platform** provides unified after-sales tickets and idempotent refunds. Product types (physical goods, cloud disk) and trade modes (normal, membership) are decoupled into two independent dimensions and can be freely combined via registries.
|
||||
|
||||
**Modular Plugin System**
|
||||
|
||||
Business capabilities are organized as pluggable **modules** (`modules/`) and **drivers** (`driver/`): currently 22 official modules (physical goods, cloud-disk products, H5 channel, WeChat Mini Program channel, check-in, AI Agent, MCP service, and more) and 30 official drivers (WeChat Pay, cloud storage, SMS, email, logistics tracking, e-waybill, e-invoice, product scraping, bots, cloud printing, and more). A fresh install defaults to 7 modules (1 required + 6 default) and 15 drivers. All support visual install / uninstall / enable / disable in the admin panel. Extensions follow a unified spec with zero-intrusion secondary development.
|
||||
|
||||
**AI-Native**
|
||||
|
||||
Built-in **AI Agent** module for admin AI chat, streaming responses, and automated tasks; **MCP service** exposes system tools to AI clients via `/api/mcp`, so humans and AI can operate the mall together. In the AI era, stability is the highest form of intelligence.
|
||||
|
||||
**Multi-Terminal**
|
||||
|
||||
One uni-app codebase covers **H5, WeChat Mini Program, and APP**. The admin panel is Vue 3 + Element Plus with front/back separation and real-time data sync for multi-channel operations.
|
||||
|
||||
---
|
||||
|
||||
### 📝 **CRMEB Ecosystem Marketplace**
|
||||
|
||||
**Ecosystem Plugins**
|
||||
|
||||
Browse the CRMEB marketplace for official curated plugins with continuous updates, professional support, and one-click upgrades — tailored to industry needs without deep technical skills.
|
||||
|
||||
**One-Click Import**
|
||||
|
||||
The system itself is plugin-based: product types, marketing campaigns, payments, logistics, distribution, content & community, analytics, third-party services, and industry customizations all plug in. Install what you need without touching core code or conflicting with upgrades. Import once and use immediately.
|
||||
|
||||
**Publish Plugins**
|
||||
|
||||
Use with ease — or earn with ease. Besides official plugins, developers can upload their own. Build industry-specific capabilities on the open-source AI foundation and list them on the marketplace. When others pay to download your work, you share the revenue — turning technical craft into income while growing the open-source mall ecosystem.
|
||||
|
||||
We are not building “just another storefront.” How far a system goes depends on how many people are willing to build on it. Missing a plugin? Tell the official team. Have a great plugin? Bring it and talk.
|
||||
|
||||
CRMEB Marketplace: <a href="https://www.crmeb.com/plugin" target="_blank">Plugin Market</a>
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
### 🧩 **Mid-platform Core Capabilities**
|
||||
|
||||
| Mid-platform | Description |
|
||||
| --- | --- |
|
||||
| **Product Mid-platform** | Product master data separated from type extensions; shared categories, SKUs, attributes, reviews, and stats across types. Modular product types with built-in physical goods and cloud-disk products; custom types supported |
|
||||
| **Trade Mode Registry** | “How a product is sold” decoupled from “what a product is”: built-in normal purchase and membership trade modes handle promotional pricing, order validation, and lifecycle hooks; more playbooks register via the registry |
|
||||
| **Order Mid-platform** | Multi-type orders unified with product snapshots and status logs; order-type adapters unify status trees, payment status, action buttons, and progress |
|
||||
| **After-sales & Refund Mid-platform** | Unified after-sales applications and line items; product types declare available after-sales types and time windows via a rule registry; unified refund tickets + idempotent refund side-effect records; supports refund-only, return & refund, exchange, and repair |
|
||||
| **User & Assets** | User-centric aggregation of addresses, tags, levels, membership, balance, points, commission, coupons, invoices, and referral relationships |
|
||||
|
||||
---
|
||||
|
||||
### 📦 **Modules & Drivers**
|
||||
|
||||
Currently 22 modules and 30 drivers. Fresh install defaults to 7 modules (1 required + 6 default) and 15 drivers. Manage them under Admin → Extension Management (physical goods is a protected base module and cannot be uninstalled).
|
||||
|
||||
**Built-in Modules**
|
||||
|
||||
| Module | Capability |
|
||||
| --- | --- |
|
||||
| Physical Goods | Common products & SKUs, logistics / delivery / pickup fulfillment (protected base module) |
|
||||
| Cloud Disk Products | Digital goods, auto-delivered after payment |
|
||||
| H5 Browser | H5 channel access and user auth |
|
||||
| WeChat Mini Program | Mini Program channel access and user auth |
|
||||
| Check-in | Consecutive / cumulative check-in rules, points rewards, records |
|
||||
| AI Agent | Admin AI chat, streaming responses, automated tasks, and run records |
|
||||
| MCP Service | `POST /api/mcp` exposes system tools to AI clients with independent key auth |
|
||||
|
||||
**Built-in Drivers**
|
||||
|
||||
| Category | Built-in Drivers |
|
||||
| --- | --- |
|
||||
| Payment | WeChat Pay, WeChat Pay V3 |
|
||||
| Cloud Storage | Local storage, Alibaba Cloud OSS |
|
||||
| SMS / Email | Yihaotong SMS, QQ Mail |
|
||||
| Bots | WeCom bot, DingTalk bot, Feishu bot |
|
||||
| Logistics & Fulfillment | Yihaotong tracking, e-waybill, e-invoice, product scraping |
|
||||
| Cloud Print | Feie Cloud, Yilianyun |
|
||||
|
||||
---
|
||||
|
||||
### 🫧 **Technical Highlights**
|
||||
|
||||
~~~
|
||||
Secondary development:
|
||||
1. Coding standards: PSR-12 naming, RESTful APIs, strict Controller→Services→Dao→Model layering, complete comments, unified response body {status,msg,data};
|
||||
2. Permission management: flexible RBAC — menus are permission rules down to each menu and action button;
|
||||
3. Module scaffolding: php think make:module generates compliant skeletons (plugins, product types, drivers, trade modes, marketing observers);
|
||||
4. Architecture guard: php think arch:check scans the three-layer dependency rule, cross-module imports, events, and comment conventions — CI-ready;
|
||||
5. System events: dual event registries for core and modules; listeners attach at event anchors and load with module install state;
|
||||
6. Scheduled tasks: dual timer systems for core and modules, auto-registered on module install;
|
||||
7. API management: visual route management, import, and online debugging in admin;
|
||||
8. Form engine: PHP form-builder on the backend, @form-create on the frontend;
|
||||
9. Code generation: quickly generate admin menus, pages, and CRUD;
|
||||
10. Docs: Chinese project docs under help/docs (overview, architecture, features, DB design, deploy, ops); admin can connect MCP knowledge services (doc/route/schema search);
|
||||
11. AI development kit: .crmeb ships AI coding rules, skills, and sub-agents compatible with mainstream AI coding tools.
|
||||
~~~
|
||||
~~~
|
||||
Performance & extensibility:
|
||||
1. Security: JWT auth, role checks, operation logs, production logs, file integrity checks, data backup;
|
||||
2. Performance: Redis cache, think-queue, Workerman long-connection gateway, multi cloud storage, cluster-ready;
|
||||
3. i18n: admin and storefront multilanguage (vue-i18n) with browser language detection;
|
||||
4. Driver extensions: ten categories — payment, upload, SMS, email, logistics, e-waybill, printing, bots, scraping, invoices — switch vendors freely for the same capability;
|
||||
5. Realtime: unified WebSocket gateway (/ws) for admin realtime notifications and more;
|
||||
6. Yihaotong: shared third-party extension for SMS, tracking, e-waybill, e-invoice, and product scraping.
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
### 📖 **Features**
|
||||
|
||||
<details>
|
||||
<summary><b>Admin features (click to expand)</b></summary>
|
||||
|
||||
- **Workbench**: business metrics, order/user trends, sales rankings, pending reminders
|
||||
- **Products**: product mid-platform, categories, multi-spec SKUs, attribute templates, reviews, scrape / import / export
|
||||
- **Orders**: unified order mid-platform list & detail, ship / split ship, price & remark edits, tracking, invoices
|
||||
- **After-sales & Refunds**: review, return receive, unified refund tickets, original-path refunds, idempotent side effects
|
||||
- **Users**: groups, tags, levels, asset adjustments, account cancellation
|
||||
- **Membership**: packages, benefits, paid membership
|
||||
- **Marketing**: coupons, points, check-in campaigns
|
||||
- **Page Builder**: DIY drag-and-drop storefront, one-click theme colors, bottom nav, splash ads, articles
|
||||
- **Store & Delivery**: pickup, clerk verification, freight templates, e-waybill, cloud print
|
||||
- **Finance**: fund flows, payment channel config, business stats
|
||||
- **Notifications**: in-app, SMS, email, Official Account templates, Mini Program subscribe messages, bots
|
||||
- **WeChat Ecosystem**: Official Account OAuth / menu / media / replies; Mini Program login / subscribe messages
|
||||
- **Extensions**: install/uninstall/enable/disable modules & drivers; admin roles & permissions; config; timers; system logs & backup
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Storefront features (click to expand)</b></summary>
|
||||
|
||||
- **Browse & Cart**: DIY home, category search, product detail, cart, combined checkout with coupons / points / balance
|
||||
- **Orders & Pay**: multi-status order management, WeChat Pay / balance, tracking, reviews
|
||||
- **After-sales**: dynamic after-sales forms, progress timeline, return logistics
|
||||
- **Profile**: balance bills, points, coupons, membership benefits, addresses, message center
|
||||
- **Engagement**: check-in for points, coupon claiming
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🔐 **Runtime Requirements**
|
||||
|
||||
| **Runtime** | **Requirement** |
|
||||
|------------------|--------------------------------------------------------------------------|
|
||||
| **OS** | Linux / Windows |
|
||||
| **Web Server** | Nginx / Apache / IIS |
|
||||
| **PHP** | PHP 8.2 ~ 8.4 (default 8.4) |
|
||||
| **Database** | MySQL 8.0 (engine: InnoDB, charset: utf8mb4, table prefix: `eb_`) |
|
||||
| **Cache** | Redis (required in production; message queue depends on it) |
|
||||
| **Process Manager** | Supervisor (for queue and other daemons) |
|
||||
| **Node.js** | >= 18 (frontend builds only) |
|
||||
| **PHP Extensions** | bcmath, curl, mbstring, json, zip, simplexml, fileinfo (optional), redis (optional) |
|
||||
| **Disabled Functions to Enable** | `proc_open`, `pcntl_signal`, `pcntl_signal_dispatch`, `pcntl_fork`, `pcntl_wait`, `pcntl_alarm` (Workerman & queue) |
|
||||
| **Queue** | `php think queue:listen --queue` (guard with Supervisor) |
|
||||
| **Timer** | `php think timer start --d` (CLI) |
|
||||
| **Long Connection** | `php think workerman start --d` (CLI, default port 40001) |
|
||||
|
||||
---
|
||||
|
||||
### 📺 **Dev Environment & Stack**
|
||||
|
||||
### **Development Environment**
|
||||
|
||||
| Tool | Version | Download |
|
||||
|--------------|--------------------|--------------------------------------------------------------------------|
|
||||
| **PHP** | 8.2 ~ 8.4 (default 8.4) | [PHP Downloads](https://www.php.net/downloads.php) |
|
||||
| **Composer** | 2.x | [Composer](https://getcomposer.org/) |
|
||||
| **MySQL** | 8.0 | [MySQL](https://www.mysql.com/) |
|
||||
| **Redis** | 6.0+ | [Redis](https://redis.io/download) |
|
||||
| **Nginx** | 1.22+ | [Nginx](http://nginx.org/en/download.html) |
|
||||
| **Node.js** | 18 / 20+ | [Node.js LTS](https://nodejs.org/en/download/releases/) |
|
||||
|
||||
### Backend Stack
|
||||
|
||||
| **Tech** | **Description** | **URL** |
|
||||
|---------------------|-----------------------------------------------------------------------------|--------------------------------------------------------------------------|
|
||||
| ThinkPHP 8 | Core framework, multi-app (`adminapi` / `api` / `outapi`) | https://www.thinkphp.cn/ |
|
||||
| think-queue | Message queue (Redis driver) | https://github.com/top-think/think-queue |
|
||||
| workerman | High-performance socket service; long-connection gateway & timers | https://www.workerman.net |
|
||||
| easywechat 6 | WeChat ecosystem (Official Account / Mini Program / Pay) | https://github.com/w7corp/easywechat |
|
||||
| php-jwt | JWT generate & verify | https://github.com/firebase/php-jwt |
|
||||
| form-builder | PHP form builder | https://form-create.com |
|
||||
| phpspreadsheet | Excel import / export | https://github.com/PHPOffice/PhpSpreadsheet |
|
||||
| guzzlehttp | HTTP client | https://guzzle-cn.readthedocs.io/zh-cn/latest |
|
||||
| alipaysdk | Alipay SDK | https://github.com/alipay/alipay-easysdk |
|
||||
| snowflake | Distributed unique IDs | https://github.com/godruoyi/php-snowflake |
|
||||
| aliyun-oss / qiniu | Cloud storage SDKs | https://github.com/aliyun/aliyun-oss-php-sdk |
|
||||
|
||||
### Admin Frontend Stack
|
||||
|
||||
| **Tech** | **Description** | **Site** |
|
||||
| --- | --- | --- |
|
||||
| vue 3.5 | Vue framework | https://vuejs.org/ |
|
||||
| element-plus 2.9 | UI framework | https://element-plus.org/ |
|
||||
| pinia 2 | State management | https://pinia.vuejs.org/ |
|
||||
| vue-router 4 | Routing | https://router.vuejs.org/ |
|
||||
| vite 5 | Build tool | https://vitejs.dev/ |
|
||||
| vxe-table 4 | Advanced table | https://vxetable.cn/ |
|
||||
| echarts 5 | Charts | https://echarts.apache.org/ |
|
||||
| wangeditor 5 | Rich text editor | https://www.wangeditor.com/ |
|
||||
| monaco-editor | Code editor | https://microsoft.github.io/monaco-editor/ |
|
||||
| axios | HTTP client | https://axios-http.com/ |
|
||||
| vue-i18n 9 | i18n | https://vue-i18n.intlify.dev/ |
|
||||
| form-create | Dynamic forms | https://form-create.com |
|
||||
| xlsx / exceljs | Excel libraries | https://sheetjs.com/ |
|
||||
| sass | CSS preprocessor | https://sass-lang.com/ |
|
||||
|
||||
### Mobile Stack
|
||||
|
||||
| **Tech** | **Description** | **Site** |
|
||||
| --- | --- | --- |
|
||||
| uni-app (official CLI + Vite 5) | Cross-end: one codebase → H5 / WeChat Mini Program / Alipay Mini Program / APP | https://uniapp.dcloud.net.cn/ |
|
||||
| vue 3 | Vue framework | https://vuejs.org/ |
|
||||
| pinia | State management | https://pinia.vuejs.org/ |
|
||||
| vue-i18n | i18n | https://vue-i18n.intlify.dev/ |
|
||||
| sass | CSS preprocessor | https://sass-lang.com/ |
|
||||
|
||||
---
|
||||
|
||||
### 🚀 **Quick Start**
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 📱 Demo
|
||||
|
||||
Admin: http://v8.crmeb.net/admin
|
||||
|
||||
Account: `demo` / Password: `crmeb.com`
|
||||
|
||||
H5: http://v8.crmeb.net/ (open on mobile)
|
||||
|
||||
|
||||
|
||||
Themes: https://www.crmeb.com/theme (desktop)
|
||||
|
||||
Plugins: https://www.crmeb.com/plugin (desktop)
|
||||
|
||||

|
||||
---
|
||||
|
||||
|
||||
**Host environment (recommended)**
|
||||
|
||||
```bash
|
||||
# 1. Backend
|
||||
cd backend
|
||||
composer install
|
||||
|
||||
# 配置 backend/.env 并确保 MySQL 5.7+(推荐 8.0)、Redis 已在宿主机运行后启动
|
||||
php think run --host 127.0.0.1 --port 8011
|
||||
```
|
||||
|
||||
数据库和 Redis 连接以 `backend/.env` 为准,宿主机常见端口为 MySQL `3306`、Redis `6379`。首次启动后访问 **http://localhost:8011** 进入安装向导。
|
||||
|
||||
## 前端一键命令
|
||||
|
||||
```bash
|
||||
./run.sh admin:dev # 管理端开发测试启动(端口 1619)
|
||||
./run.sh admin:build # 管理端编译打包并发布到 backend/public/admin
|
||||
./run.sh uni:dev # 移动端 H5 开发测试启动
|
||||
./run.sh uni:build # 移动端 H5 编译打包并发布到 backend/public
|
||||
```
|
||||
|
||||
也可直接执行 `./run.sh` 打开交互式菜单(前端命令对应编号 11/12/21/22)。
|
||||
|
||||
## 可选 Docker Compose
|
||||
|
||||
只有明确选择容器环境时使用:
|
||||
|
||||
```bash
|
||||
# 首次且允许清空本地数据时才执行
|
||||
./run.sh install
|
||||
|
||||
# 日常启停和检查
|
||||
./run.sh start
|
||||
./run.sh stop
|
||||
./run.sh restart
|
||||
|
||||
./run.sh ps
|
||||
./run.sh logs
|
||||
```
|
||||
|
||||
### 容器说明
|
||||
|
||||
| 服务 | 端口 |
|
||||
|------|------|
|
||||
| Nginx(Web 入口) | `8011` |
|
||||
| MySQL 8.0(兼容 5.7+) | `33061` |
|
||||
| Redis | `63791` |
|
||||
| Workerman(WebSocket) | `40001` |
|
||||
|
||||
另有 `phpfpm`、`timer`(定时任务)、`queue`(队列消费)为内部服务,无需对外访问。
|
||||
|
||||
Dev Containers 配置(`.devcontainer/`)不在仓库内,已在 `.gitignore` 中忽略。需要容器化开发环境者可在仓库根自建,编辑器即可「Reopen in Container」,无需入库。
|
||||
|
||||
---
|
||||
|
||||
## 首次安装配置
|
||||
|
||||
1. 创建数据库,导入 `backend/public/install/crmeb_table.sql`(表结构)与 `backend/public/install/data/` 下的基础数据(推荐直接访问安装向导自动完成)
|
||||
2. 复制 `backend/.env.example` 为 `backend/.env`,按实际环境修改(文件内有逐项注释)
|
||||
3. 设置 `backend/runtime`、`backend/public` 目录权限为 777
|
||||
4. 访问 `http://域名/admin`,默认账号 `admin` / 密码 `crmeb.com`
|
||||
|
||||
### .env 配置参考
|
||||
|
||||
```ini
|
||||
APP_DEBUG = true
|
||||
|
||||
[APP]
|
||||
DEFAULT_TIMEZONE = Asia/Shanghai
|
||||
APP_KEY = your_app_key
|
||||
|
||||
[DATABASE]
|
||||
TYPE = mysql
|
||||
HOSTNAME = 127.0.0.1
|
||||
HOSTPORT = 3306
|
||||
DATABASE = crmeb
|
||||
USERNAME = root
|
||||
PASSWORD = root
|
||||
PREFIX = eb_
|
||||
CHARSET = utf8mb4
|
||||
|
||||
[LANG]
|
||||
default_lang = zh-cn
|
||||
|
||||
[CACHE]
|
||||
DRIVER = redis
|
||||
CACHE_PREFIX = cache_xxx:
|
||||
CACHE_TAG_PREFIX = cache_tag_xxx:
|
||||
|
||||
[REDIS]
|
||||
REDIS_HOSTNAME = 127.0.0.1
|
||||
PORT = 6379
|
||||
REDIS_PASSWORD =
|
||||
SELECT = 0
|
||||
|
||||
[QUEUE]
|
||||
QUEUE_NAME = your_queue_prefix
|
||||
```
|
||||
|
||||
> 完整配置模板与逐项注释见 [backend/.env.example](backend/.env.example)。
|
||||
|
||||
---
|
||||
|
||||
## 管理端(frontend/admin)
|
||||
|
||||
```bash
|
||||
# 2. Admin (dev server default port 1619; /adminapi proxies to 8011)
|
||||
cd frontend/admin
|
||||
npm install
|
||||
npm run dev # 开发模式,自动打开浏览器
|
||||
```
|
||||
npm ci
|
||||
npm run dev
|
||||
|
||||
- 开发地址 **http://localhost:1619**;Vite 已把 `/adminapi` 代理到 `http://localhost:8011`(见 [vite.config.js](frontend/admin/vite.config.js)),需先启动后端
|
||||
- 生产构建:`npm run build`,产物部署到后端 `public/admin/`
|
||||
- 接口地址由 `VITE_APP_API_URL` 控制,留空 = 同源 `/adminapi`
|
||||
|
||||
## 移动端(frontend/uni-app)
|
||||
|
||||
```bash
|
||||
# 3. Mobile
|
||||
cd frontend/uni-app
|
||||
npm install
|
||||
npm run dev:h5 # H5 开发模式
|
||||
npm run dev:h5 # H5; WeChat Mini Program: npm run dev:mp-weixin
|
||||
```
|
||||
|
||||
- H5 开发地址默认 **http://localhost:5173**;接口地址 `UNI_APP_API_URL` 已在 `.env.development` 默认指向 `http://localhost:8011`
|
||||
- 微信小程序:`npm run dev:mp-weixin`,再用微信开发者工具导入 `dist/dev/mp-weixin`;支付宝小程序用 `dev:mp-alipay`;APP 用 `dev:app`
|
||||
- 生产构建:`npm run build:h5` / `build:mp-weixin` / `build:app`
|
||||
**Backend daemons**
|
||||
|
||||
---
|
||||
|
||||
## 后台服务
|
||||
|
||||
### 消息队列
|
||||
```bash
|
||||
cd backend
|
||||
php think queue:listen --queue
|
||||
php think queue:listen --queue # Message queue (Supervisor)
|
||||
php think timer start --d # Scheduled tasks
|
||||
php think workerman start --d # WebSocket long-connection gateway
|
||||
```
|
||||
|
||||
### 定时任务
|
||||
(自动收货、库存预警等)
|
||||
**Docker Compose (optional)**
|
||||
|
||||
```bash
|
||||
php think timer start --d # 启动
|
||||
php think timer stop # 停止
|
||||
php think timer restart # 重启
|
||||
./run.sh start # Start (Nginx 8011 / MySQL 33061 / Redis 63791 / WebSocket 40001)
|
||||
./run.sh ps # Status
|
||||
./run.sh logs # Logs
|
||||
```
|
||||
|
||||
### 长连接服务(WebSocket)
|
||||
(H5 聊天、管理员消息通知)
|
||||
**Frontend one-liners (`run.sh`)**
|
||||
|
||||
```bash
|
||||
php think workerman start --d
|
||||
php think workerman stop
|
||||
php think workerman restart
|
||||
./run.sh admin:dev # Admin dev server (port 1619)
|
||||
./run.sh admin:build # Build admin and publish to backend/public/admin
|
||||
./run.sh uni:dev # Mobile H5 dev server
|
||||
./run.sh uni:build # Build mobile H5 and publish to backend/public
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [文档索引](help/docs/文档索引.md) | 项目、功能、开发、部署与运维阅读导航 |
|
||||
| [架构设计](help/docs/项目架构.md) | 系统架构、技术栈、目录结构 |
|
||||
| [模块与驱动](help/docs/模块与驱动.md) | 默认安装清单与各业务模块功能说明 |
|
||||
| [管理端 API](help/docs/api/管理端接口.md) | AdminAPI 接口文档 |
|
||||
| [用户端 API](help/docs/api/用户端接口.md) | 用户端 API 接口文档 |
|
||||
| [MCP 接口](help/docs/api/MCP接口.md) | 独立 MCP 服务的接入、认证、工具与排障说明 |
|
||||
| [数据库设计](help/docs/数据库设计.md) | 数据库表结构设计 |
|
||||
| [开发指南](help/docs/开发指南.md) | 开发环境、规范、流程 |
|
||||
|
||||
外部资源:
|
||||
- [CRMEB 使用手册](https://doc.crmeb.com)
|
||||
- [ThinkPHP 8.0 开发手册](https://doc.thinkphp.cn/v8_0/)
|
||||
> Default URLs: `http://localhost:8011`, admin at `http://localhost:8011/admin`.
|
||||
> DB and other settings follow `backend/.env`. Full deploy guide: [部署指南](help/docs/部署指南.md).
|
||||
|
||||
---
|
||||
|
||||
## 多应用结构
|
||||
### 📖 **Documentation**
|
||||
|
||||
URL 前缀自动路由到对应应用:
|
||||
Full Chinese docs ship under `help/docs/`:
|
||||
|
||||
| 前缀 | 应用 | 说明 |
|
||||
|------|------|------|
|
||||
| `/admin` | 管理端静态页面 | Vue 管理后台入口,不是 API 前缀 |
|
||||
| `/adminapi` | `app/adminapi/` + 模块 `route/admin.php` | 管理后台 API |
|
||||
| `/api` | `app/api/` + 模块 `route/user.php` | 商城用户 API、模块用户端接口 |
|
||||
| Doc | Content |
|
||||
| --- | --- |
|
||||
| [项目说明](help/docs/项目说明.md) | Positioning, roles, entry points, repo layout, quick start |
|
||||
| [项目功能](help/docs/项目功能.md) | Full admin / storefront feature list and core flows |
|
||||
| [项目架构](help/docs/项目架构.md) | Layered architecture, modules & drivers, registries |
|
||||
| [模块与驱动](help/docs/模块与驱动.md) | Built-in modules and drivers |
|
||||
| [数据库设计](help/docs/数据库设计.md) | Core table structures and relationships |
|
||||
| [开发指南](help/docs/开发指南.md) | Local development, regression scripts, debugging |
|
||||
| [部署指南](help/docs/部署指南.md) | Host and Docker deployment |
|
||||
| [运维手册](help/docs/运维手册.md) | Day-to-day ops, backup/restore, troubleshooting |
|
||||
| [安全与配置](help/docs/安全与配置.md) | Config reference and security baseline |
|
||||
| [API Docs](help/docs/api/) | Admin / storefront API index |
|
||||
|
||||
🔗 <a href="https://gitee.com/xazbkj/crmebbz/issues" target="_blank">Feedback</a> | 📩 <a href="https://gitee.com/xazbkj/crmebbz/pulls" target="_blank">Contribute</a>
|
||||
|
||||
---
|
||||
|
||||
## 开发规范
|
||||
### 📞 **Community**
|
||||
|
||||
### 命名规范(PSR-2 + PSR-4)
|
||||
|
||||
| 元素 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 类名 / 接口 / Trait | 大驼峰 | `User`, `UserType` |
|
||||
| 方法(控制器除外) | 小驼峰 | `getUserName()` |
|
||||
| 属性 / 变量 | 小驼峰 | `tableName` |
|
||||
| 控制器方法 | 小写+下划线 | `get_client_ip` |
|
||||
| 目录 | 小写+下划线 | `store_product/` |
|
||||
| 常量 / 环境变量 | 大写+下划线 | `APP_DEBUG` |
|
||||
| 数据表 / 字段 | 小写+下划线,`eb_` 前缀 | `eb_user`, `user_name` |
|
||||
|
||||
### CRMEB 核心规范
|
||||
|
||||
- **验证**:数据验证放在模块的 `validates/` 目录下
|
||||
- **JSON 响应**:统一使用 `app('json')->success()` / `app('json')->fail()`
|
||||
- **错误处理**:抛异常统一控制输出 —— `throw new AuthException('错误信息', 400)`
|
||||
- **数据库**:统一使用模型操作,**禁止 `Db::table()`**
|
||||
- **请求参数**:使用 `$request->getMore([...])` 获取表单数据
|
||||
- **分层职责**:
|
||||
- Model 层 —— 仅写搜索条件和关联关系
|
||||
- Services 层 —— 数据组合查询、业务逻辑(创建命令:`php make:services api@user/User`)
|
||||
- **代码风格**:使用 PHP 8 新语法,严格类型声明
|
||||
Bug reports and suggestions via Gitee Issues; contributions welcome via Pull Request.
|
||||
|
||||
---
|
||||
|
||||
## 用 AI 开发本项目
|
||||
❤️ Open source, commercially ready
|
||||
|
||||
本仓库为所有 AI 编码工具提供统一约定:规则、技能、子智能体唯一真源在 [.crmeb/](.crmeb/AGENTS.md),各工具入口只做指引、不承载正文。
|
||||
|
||||
| 工具 | 使用方式 |
|
||||
|------|------|
|
||||
| Codex / Cursor / Gemini CLI 等支持 AGENTS.md 标准的工具 | 自动读取根 [AGENTS.md](AGENTS.md),无需配置 |
|
||||
| Claude Code | 自动读取 [CLAUDE.md](CLAUDE.md) |
|
||||
| 支持 llms.txt 的工具 / 网页抓取类 AI | 读取 [llms.txt](llms.txt) |
|
||||
|
||||
所有入口最终指向 [.crmeb/AGENTS.md](.crmeb/AGENTS.md):其中定义了项目硬约束(依赖方向、分层、插件零侵入红线)、16 条开发规则、8 个开发技能与 3 个专项子智能体,AI 按索引按需加载即可获得与内部开发者一致的约定。
|
||||
|
||||
### 连接本项目的 MCP 开发工具
|
||||
|
||||
内置 MCP 服务(`service_mcp` 模块,默认安装)向任意支持 MCP 的 AI 工具开放实时项目知识:
|
||||
|
||||
完整的安装启用、认证、业务工具、写入授权和排障说明见 [MCP 接口文档](help/docs/api/MCP接口.md)。
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| `crmeb_dev_docs_search` | 检索开发规则 / 技能手册 / 项目文档(架构铁律、插件规范、事件规范等) |
|
||||
| `crmeb_dev_route_search` | 按关键词 / 应用检索接口路由定义(定位「接口在哪、控制器是谁」) |
|
||||
| `crmeb_dev_table_schema` | 查询运行库真实表结构:字段、注释、索引(含已安装插件表) |
|
||||
|
||||
接入步骤:
|
||||
|
||||
1. 启动后端后,登录管理后台「设置 → MCP 服务」,开启开关并复制访问密钥(appkey);
|
||||
2. 在 AI 工具的 MCP 配置中添加(Claude Code 写入 `.mcp.json`,Cursor / Trae 等在各自 MCP 设置中填写):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"crmeb": {
|
||||
"type": "http",
|
||||
"name": "CRMEB 商城",
|
||||
"url": "http://localhost:8011/api/mcp",
|
||||
"headers": { "X-Mcp-Key": "你的appkey" },
|
||||
"timeout": 15,
|
||||
"description": "CRMEB 商城管理工具集:查询/管理商城数据(商品、订单、售后、配置等,以账号已授权工具为准)。",
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Docker 部署无需额外配置(`docker-compose.yml` 已为容器只读挂载文档目录);文档检索工具定位仓库根依赖 `CRMEB_REPO_ROOT` 环境变量,默认取 `backend/` 的父目录。
|
||||
|
||||
工具的返回为检索摘要,正文以仓库文件为准;工具实现位于 `backend/modules/service/mcp/tools/`,按约定新增 `*Tool.php` 即自动注册。
|
||||
|
||||
---
|
||||
|
||||
## 参与开发
|
||||
|
||||
提交流程、检查清单与规范入口见 [CONTRIBUTING.md](CONTRIBUTING.md);上游项目参阅 [CRMEB](https://github.com/crmeb/CRMEB)。
|
||||
|
||||
---
|
||||
|
||||
## 版权信息
|
||||
|
||||
版权所有 Copyright © 2016~2026 by CRMEB (http://www.crmeb.com)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
CRMEB® 商标和著作权所有者为西安众邦网络科技有限公司。
|
||||
Deeply upgraded from the CRMEB open-source edition, released under Apache-2.0.
|
||||
|
||||
59
README_ZH.md
59
README_ZH.md
@ -2,7 +2,7 @@
|
||||
|
||||
# CRMEB 高品质开源商城系统 v8(PHP 版)
|
||||
|
||||
**中台化架构 · 模块化扩展 · AI 原生 · 三端一体**
|
||||
**中台化架构 · 模块化扩展 · AI 架构 · 多端一体**
|
||||
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
**开源自由**
|
||||
|
||||
CRMEB 开源商城系统代码 100% 开源,基于 **Apache-2.0 协议** 免费商用,无任何隐藏费用或功能限制,真正实现"零成本"部署与二次开发自由!
|
||||
CRMEB 开源商城系统代码 100% 开源,基于 **Apache-2.0 协议** 免费商用,真正实现"零成本"部署与二次开发自由!全开源AI 时代新商城底座。
|
||||
|
||||
**中台化架构**
|
||||
|
||||
@ -36,11 +36,34 @@ v8 全新升级为中台化电商架构:**商品中台**统一管理多产品
|
||||
|
||||
**AI 原生**
|
||||
|
||||
内置 **AI Agent 模块**,提供后台 AI 对话、流式响应与自动化任务;**MCP 服务**通过 `/api/mcp` 端点向 AI 客户端开放系统工具,让人与 AI 协同运营商城。
|
||||
内置 **AI Agent 模块**,提供后台 AI 对话、流式响应与自动化任务;**MCP 服务**通过 `/api/mcp` 端点向 AI 客户端开放系统工具,让人与 AI 协同运营商城。AI 时代,稳定才是最高级的智能。
|
||||
|
||||
**多端一体**
|
||||
|
||||
一套 uni-app 源码覆盖 **H5、微信小程序、支付宝小程序、APP**,管理后台基于 Vue 3 + Element Plus,前后端分离设计,数据实时互通,助力商家一站式运营多渠道业务。
|
||||
一套 uni-app 源码覆盖 **H5、微信小程序、APP**,管理后台基于 Vue 3 + Element Plus,前后端分离设计,数据实时互通,助力商家一站式运营多渠道业务。
|
||||
|
||||
---
|
||||
|
||||
### 📝 **CRMEB生态市场**
|
||||
|
||||
**生态插件**
|
||||
|
||||
来CRMEB生态市场,官方精选插件,享受持续更新、专业服务与一键升级体验,满足不同行业的功能要求。无需具备专业技术开发能力,海量功能插件,可精准匹配不同行业需求,快速打造您的专属在线电商系统。
|
||||
|
||||
**一键导入**
|
||||
|
||||
系统本身就以插件化方式构建:商品类型、营销活动、支付方式、物流配送、分销、内容与社区、数据分析、第三方服务、行业定制,全部以插件形式接入。需要什么装什么,不侵入核心代码,升级不冲突。一键导入安装后直接使用。
|
||||
|
||||
**上架插件**
|
||||
|
||||
不仅省心用,更能轻松赚。除了官方插件外,也支持开发者上传自己写的插件——你做的能力,可以被更多商家装上。您可以利用全开源代码,结合系统AI底座,轻松开发打造出独具行业特色的专属功能插件,并直接上架插件市场。当其他用户付费下载您的作品时,您将获得相应的收益分成。这不仅为您的技术能力与技术沉淀提供了直接的变现渠道,更助力共建繁荣的开源商城生态,实现创意与价值的双赢。
|
||||
|
||||
我们做这件事,不是为了多一个卖东西的地方。是因为一套系统能走多远,取决于有多少人愿意在上面做事,携手共同打造一款好用的系统。缺什么插件,给官方直接反馈;有好插件,欢迎带着来聊。
|
||||
|
||||
CRMEB生态市场:<a href="https://www.crmeb.com/plugin" target="_blank">插件市场</a>
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
@ -85,6 +108,7 @@ v8 全新升级为中台化电商架构:**商品中台**统一管理多产品
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 🫧 **技术特点**
|
||||
|
||||
~~~
|
||||
@ -153,7 +177,7 @@ v8 全新升级为中台化电商架构:**商品中台**统一管理多产品
|
||||
|------------------|--------------------------------------------------------------------------|
|
||||
| **操作系统** | Linux / Windows |
|
||||
| **WEB 服务** | Nginx / Apache / IIS |
|
||||
| **PHP 版本** | PHP 8.2 ~ 8.4 |
|
||||
| **PHP 版本** | PHP 8.2 ~ 8.4(默认 8.4) |
|
||||
| **数据库** | MySQL 8.0(引擎:InnoDB,字符集:utf8mb4,表前缀:eb_) |
|
||||
| **缓存** | Redis(生产环境必装,消息队列依赖) |
|
||||
| **管理器** | Supervisor(用于管理消息队列等常驻进程) |
|
||||
@ -172,7 +196,7 @@ v8 全新升级为中台化电商架构:**商品中台**统一管理多产品
|
||||
|
||||
| 工具 | 版本 | 下载链接 |
|
||||
|--------------|--------------------|--------------------------------------------------------------------------|
|
||||
| **PHP** | 8.2 ~ 8.4 | [PHP 官方下载](https://www.php.net/downloads.php) |
|
||||
| **PHP** | 8.2 ~ 8.4(默认 8.4) | [PHP 官方下载](https://www.php.net/downloads.php) |
|
||||
| **Composer** | 2.x | [Composer 官网](https://getcomposer.org/) |
|
||||
| **MySQL** | 8.0 | [MySQL 官网](https://www.mysql.com/) |
|
||||
| **Redis** | 6.0+ | [Redis 官网](https://redis.io/download) |
|
||||
@ -211,7 +235,7 @@ v8 全新升级为中台化电商架构:**商品中台**统一管理多产品
|
||||
| axios | HTTP 客户端 | https://axios-http.com/ |
|
||||
| vue-i18n 9 | 多语言 | https://vue-i18n.intlify.dev/ |
|
||||
| form-create | 动态表单渲染 | https://form-create.com |
|
||||
| xlsx / exceljs | Excel 处理库 | https://sheetjs.com/ |
|
||||
| xlsx | Excel 处理库 | https://sheetjs.com/ |
|
||||
| sass | CSS 预处理器 | https://sass-lang.com/ |
|
||||
|
||||
### 移动端技术栈
|
||||
@ -228,6 +252,27 @@ v8 全新升级为中台化电商架构:**商品中台**统一管理多产品
|
||||
|
||||
### 🚀 **快速开始**
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 📱 系统演示
|
||||
|
||||
管理后台: http://v8.crmeb.net/admin
|
||||
|
||||
账号:demo 密码:crmeb.com
|
||||
|
||||
H5端:http://v8.crmeb.net/ (移动端打开)
|
||||
|
||||
|
||||
|
||||
主题:https://www.crmeb.com/theme (电脑端打开)
|
||||
|
||||
插件:https://www.crmeb.com/plugin(电脑端打开)
|
||||
|
||||

|
||||
---
|
||||
|
||||
|
||||
**宿主机环境(推荐)**
|
||||
|
||||
```bash
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
# 仓库内 backend/.env 已是容器默认配置(MySQL/Redis 主机为容器名)。
|
||||
# - APP_KEY 建议替换为随机 32 位十六进制字符串。
|
||||
# - 生产环境务必:APP_DEBUG = false,并修改数据库/Redis 密码。
|
||||
# - 定时任务 /api/crontab/* 建议配置独立 CRONTAB_KEY(与 APP_KEY 分离);
|
||||
# 可选 CRONTAB_ALLOW_IPS 限制触发来源(逗号分隔)。
|
||||
# =====================================================================
|
||||
|
||||
APP_DEBUG = true
|
||||
@ -15,6 +17,12 @@ APP_DEBUG = true
|
||||
DEFAULT_TIMEZONE = Asia/Shanghai
|
||||
APP_KEY = app_key_REPLACE_WITH_RANDOM_HEX
|
||||
|
||||
[CRONTAB]
|
||||
# 独立定时任务密钥;留空则回退使用 APP_KEY(不推荐生产)
|
||||
KEY =
|
||||
# 可选:允许触发的来源 IP,逗号分隔;留空不限制
|
||||
ALLOW_IPS =
|
||||
|
||||
[DATABASE]
|
||||
TYPE = mysql
|
||||
# 本机手动部署填 127.0.0.1;仓库自带 Docker 填容器名 crmeb_mysql
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
version=CRMEB-KY v8.0.0
|
||||
version_code=800
|
||||
platform=GITEE
|
||||
platform=GITHUB
|
||||
app_id=ze7x9rxsv09l6pvsyo
|
||||
app_key=fuF7U9zaybLa5gageVQzxtxQMFnvU2OI
|
||||
|
||||
@ -238,6 +238,8 @@ class CommonController extends AuthController
|
||||
'url' => '/' . Config::get('app.admin_prefix', 'admin') . '/product/product_reply?is_reply=0'
|
||||
];
|
||||
}
|
||||
//一号通短信余额检查(监听器内有节流)
|
||||
event('SmsBalanceCheck');
|
||||
return app('json')->success($this->noticeData($value));
|
||||
}
|
||||
|
||||
|
||||
@ -111,8 +111,32 @@ class LoginController extends AuthController
|
||||
}
|
||||
|
||||
$this->validate(['account' => $account, 'pwd' => $password], \app\adminapi\validate\setting\SystemAdminValidata::class, 'get');
|
||||
|
||||
// 登录失败锁定:按 账号+IP 计数,5 次锁 1 分钟、10 次锁 2 分钟、20 次及以上锁 5 分钟(封顶 5 分钟),防在线爆破(安全审计 A4)
|
||||
$failKey = 'admin_login_fail:' . md5(strtolower(trim((string)$account)) . '|' . $this->request->ip());
|
||||
$lockKey = 'admin_login_lock:' . md5(strtolower(trim((string)$account)) . '|' . $this->request->ip());
|
||||
$lockExpiry = (int)CacheService::get($lockKey, 0);
|
||||
if ($lockExpiry > time()) {
|
||||
return app('json')->fail('失败次数过多,账号已锁定,请 ' . ($lockExpiry - time()) . ' 秒后重试');
|
||||
}
|
||||
|
||||
$result = $this->services->login($account, $password, 'admin', $key);
|
||||
if (!$result) {
|
||||
// 累计失败次数并按档位设置锁定到期时间(同档重复失败仅顺延不叠加)
|
||||
$failNum = (int)CacheService::get($failKey, 0) + 1;
|
||||
CacheService::set($failKey, $failNum, 600);
|
||||
$lockMinutes = 0;
|
||||
if ($failNum >= 20) {
|
||||
$lockMinutes = 5;
|
||||
} elseif ($failNum >= 10) {
|
||||
$lockMinutes = 2;
|
||||
} elseif ($failNum >= 5) {
|
||||
$lockMinutes = 1;
|
||||
}
|
||||
if ($lockMinutes > 0) {
|
||||
$newExpiry = time() + $lockMinutes * 60;
|
||||
CacheService::set($lockKey, max((int)CacheService::get($lockKey, 0), $newExpiry), 600);
|
||||
}
|
||||
$num = CacheService::get('login_captcha', 1);
|
||||
if ($num > 1) {
|
||||
return app('json')->fail('账号或密码错误', ['login_captcha' => 1]);
|
||||
@ -120,6 +144,9 @@ class LoginController extends AuthController
|
||||
CacheService::set('login_captcha', $num + 1, 60);
|
||||
return app('json')->fail('账号或密码错误', ['login_captcha' => 0]);
|
||||
}
|
||||
// 登录成功清空该账号的失败计数与锁定状态
|
||||
CacheService::delete($failKey);
|
||||
CacheService::delete($lockKey);
|
||||
CacheService::delete('login_captcha');
|
||||
return app('json')->success($result);
|
||||
}
|
||||
|
||||
@ -288,7 +288,11 @@ class UploadDriverController extends AuthController
|
||||
}
|
||||
}
|
||||
// 基本信息、启用状态、凭据及图片处理配置在同一事务中提交。
|
||||
$this->services->update((int)$id, $updateData, $configValues ?: null);
|
||||
// 无使用中的空间时仍允许保存基础配置,仅阻止启用云存储驱动。
|
||||
$result = $this->services->update((int)$id, $updateData, $configValues ?: null);
|
||||
if ($result === 'config_saved_enable_blocked') {
|
||||
return app('json')->success('配置已保存。请先在「空间配置」添加并开启存储空间后,再启用该驱动');
|
||||
}
|
||||
|
||||
return app('json')->success('保存成功');
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ namespace app\adminapi\controller\order;
|
||||
use app\adminapi\controller\AuthController;
|
||||
use app\dao\driver\pay\PayChannelDao;
|
||||
use app\services\order\OrderCenterServices;
|
||||
use app\services\pay\PayRecordServices;
|
||||
use crmeb\trade\mode\TradeModeRegistry;
|
||||
use crmeb\trade\order\type\OrderTypeRegistry;
|
||||
use crmeb\trade\product\ProductTypeRegistry;
|
||||
@ -174,4 +175,19 @@ class OrderCenterController extends AuthController
|
||||
'pay_types' => $payTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对已取消但仍收到付款的订单提交一次原路退款。
|
||||
* @param PayRecordServices $records 支付记录
|
||||
* @return mixed
|
||||
*/
|
||||
public function submitCanceledRefund(PayRecordServices $records)
|
||||
{
|
||||
$orderNo = (string)$this->request->post('order_no', '');
|
||||
if ($orderNo === '') {
|
||||
return app('json')->fail('缺少订单号');
|
||||
}
|
||||
$records->submitCanceledRefund($orderNo);
|
||||
return app('json')->success('已提交原路退款');
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ use crmeb\plugin\PluginManager;
|
||||
use crmeb\plugin\PluginManifest;
|
||||
use crmeb\utils\SafeZip;
|
||||
use think\facade\App;
|
||||
use think\facade\Env;
|
||||
|
||||
/**
|
||||
* 插件管理控制器
|
||||
@ -127,6 +128,10 @@ class PluginController extends AuthController
|
||||
*/
|
||||
public function upload()
|
||||
{
|
||||
if (!Env::get('app_debug', false)) {
|
||||
return app('json')->fail('生产环境无法上传插件,如需使用请修改.env文件中app_debug项为true');
|
||||
}
|
||||
|
||||
$file = $this->request->file('plugin_package');
|
||||
if (!$file) {
|
||||
return app('json')->fail('请上传插件安装包(ZIP 格式)');
|
||||
|
||||
@ -16,6 +16,7 @@ use app\services\system\config\SystemConfigServices;
|
||||
use app\services\system\config\SystemConfigTabServices;
|
||||
use app\services\system\SystemPemServices;
|
||||
use crmeb\services\CacheService;
|
||||
use crmeb\utils\SafeZip;
|
||||
use think\facade\App;
|
||||
|
||||
/**
|
||||
@ -404,17 +405,36 @@ class SystemConfigController extends AuthController
|
||||
return app('json')->fail('金额最多两位小数');
|
||||
}
|
||||
|
||||
if (isset($post['weixin_ckeck_file'])) {
|
||||
$from = public_path() . $post['weixin_ckeck_file'];
|
||||
$to = public_path() . array_reverse(explode('/', $post['weixin_ckeck_file']))[0];
|
||||
@copy($from, $to);
|
||||
// 仅允许从 public 目录内已上传文件复制到 Web 根固定位置,拒绝 ../ 路径穿越
|
||||
if (!empty($post['weixin_ckeck_file'])) {
|
||||
try {
|
||||
$from = SafeZip::resolveUnder(public_path(), (string)$post['weixin_ckeck_file']);
|
||||
} catch (\Throwable $e) {
|
||||
return app('json')->fail('微信校验文件路径非法');
|
||||
}
|
||||
$name = basename($from);
|
||||
if ($name === '' || $name === '.' || $name === '..') {
|
||||
return app('json')->fail('微信校验文件名非法');
|
||||
}
|
||||
$to = rtrim(public_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
|
||||
if (!@copy($from, $to)) {
|
||||
return app('json')->fail('微信校验文件复制失败');
|
||||
}
|
||||
}
|
||||
if (isset($post['ico_path'])) {
|
||||
$from = public_path() . $post['ico_path'];
|
||||
if (!empty($post['ico_path'])) {
|
||||
try {
|
||||
$from = SafeZip::resolveUnder(public_path(), (string)$post['ico_path']);
|
||||
} catch (\Throwable $e) {
|
||||
return app('json')->fail('ICO 图标路径非法');
|
||||
}
|
||||
if (strtolower(pathinfo($from, PATHINFO_EXTENSION)) !== 'ico') {
|
||||
return app('json')->fail('ICO 图标必须是 .ico 文件');
|
||||
}
|
||||
$toAdmin = public_path('admin') . 'favicon.ico';
|
||||
$toPublic = public_path() . 'favicon.ico';
|
||||
@copy($from, $toAdmin);
|
||||
@copy($from, $toPublic);
|
||||
$toPublic = rtrim(public_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'favicon.ico';
|
||||
if (!@copy($from, $toAdmin) || !@copy($from, $toPublic)) {
|
||||
return app('json')->fail('ICO 图标复制失败');
|
||||
}
|
||||
}
|
||||
if (isset($post['sign_give_point'])) {
|
||||
if (!is_int($post['sign_give_point']) || $post['sign_give_point'] < 0) {
|
||||
@ -543,11 +563,20 @@ class SystemConfigController extends AuthController
|
||||
if (strstr($path, 'http://') || strstr($path, 'https://')) {
|
||||
$path = parse_url($path)['path'] ?? '';
|
||||
}
|
||||
$path = root_path('runtime/pem') . ltrim($path, '/');
|
||||
if (!file_exists($path)) {
|
||||
$path = public_path('uploads') . ltrim($path, '/');
|
||||
$path = ltrim(str_replace('\\', '/', $path), '/');
|
||||
// realpath 归一化后限定 runtime/pem 与 public/uploads 两个白名单根目录内,防 ../../ 穿越(安全审计 C3)
|
||||
foreach ([root_path('runtime/pem'), public_path('uploads')] as $root) {
|
||||
$rootReal = realpath($root);
|
||||
if ($rootReal === false) {
|
||||
continue;
|
||||
}
|
||||
$candidate = realpath($root . $path);
|
||||
if ($candidate !== false && is_file($candidate)
|
||||
&& strpos($candidate, $rootReal . DIRECTORY_SEPARATOR) === 0) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return $path;
|
||||
throw new \RuntimeException('证书文件路径不合法或文件不存在');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -69,24 +69,48 @@ class SystemCrudController extends AuthController
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证路径
|
||||
* 归一化相对路径:消除 `..`/`.` 片段与反斜杠,阻断 `../` 穿越绕过前缀校验(安全审计 C1)
|
||||
* @param string $path
|
||||
* @return string 归一化后的相对路径,非法输入返回空串
|
||||
*/
|
||||
protected function normalizeCrudPath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', (string)$path);
|
||||
if (strpos($path, "\0") !== false) return '';
|
||||
$parts = [];
|
||||
foreach (explode('/', $path) as $seg) {
|
||||
if ($seg === '' || $seg === '.') continue;
|
||||
if ($seg === '..') {
|
||||
array_pop($parts);
|
||||
continue;
|
||||
}
|
||||
$parts[] = $seg;
|
||||
}
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证路径(归一化后再判前缀,`../` 逃逸不再可能通过,安全审计 C1)
|
||||
* @param $data
|
||||
* @return bool
|
||||
* @author wuhaotian
|
||||
* @email 442384644@qq.com
|
||||
* @email 442404644@qq.com
|
||||
* @date 2024/2/19
|
||||
*/
|
||||
public function crudVerifyPath($data)
|
||||
{
|
||||
if (strpos($data['controller'], 'app' . DS . 'adminapi' . DS . 'controller' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['validate'], 'app' . DS . 'adminapi' . DS . 'validate' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['service'], 'app' . DS . 'services' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['dao'], 'app' . DS . 'dao' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['model'], 'app' . DS . 'model' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['route'], 'app' . DS . 'adminapi' . DS . 'route' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['router'], 'router' . DS . 'modules' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['api'], 'api' . DS . 'crud' . DS) !== 0) return false;
|
||||
if (strpos($data['pages'], 'pages' . DS . 'crud' . DS) !== 0) return false;
|
||||
foreach (['controller', 'validate', 'service', 'dao', 'model', 'route', 'router', 'api', 'pages'] as $key) {
|
||||
$data[$key] = $this->normalizeCrudPath((string)($data[$key] ?? ''));
|
||||
}
|
||||
if (strpos($data['controller'], 'app/adminapi/controller/crud/') !== 0) return false;
|
||||
if (strpos($data['validate'], 'app/adminapi/validate/crud/') !== 0) return false;
|
||||
if (strpos($data['service'], 'app/services/crud/') !== 0) return false;
|
||||
if (strpos($data['dao'], 'app/dao/crud/') !== 0) return false;
|
||||
if (strpos($data['model'], 'app/model/crud/') !== 0) return false;
|
||||
if (strpos($data['route'], 'app/adminapi/route/crud/') !== 0) return false;
|
||||
if (strpos($data['router'], 'router/modules/crud/') !== 0) return false;
|
||||
if (strpos($data['api'], 'api/crud/') !== 0) return false;
|
||||
if (strpos($data['pages'], 'pages/crud/') !== 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -318,11 +342,19 @@ class SystemCrudController extends AuthController
|
||||
]);
|
||||
|
||||
$data = [];
|
||||
// 存库路径同样归一化校验后再读文件(安全审计 C1:读收敛白名单根目录)
|
||||
$verifyData = [];
|
||||
foreach ($make as $key => $item) {
|
||||
$verifyData[$key] = $this->normalizeCrudPath((string)($item['path'] ?? ''));
|
||||
}
|
||||
if (!$this->crudVerifyPath($verifyData)) {
|
||||
throw new \RuntimeException('生成文件路径不合法');
|
||||
}
|
||||
foreach ($make as $key => $item) {
|
||||
if (in_array($key, ['pages', 'router', 'api'])) {
|
||||
$path = Make::adminTemplatePath() . $item['path'];
|
||||
$path = Make::adminTemplatePath() . $verifyData[$key];
|
||||
} else {
|
||||
$path = app()->getRootPath() . $item['path'];
|
||||
$path = app()->getRootPath() . $verifyData[$key];
|
||||
}
|
||||
$item['name'] = $item['path'];
|
||||
try {
|
||||
@ -595,7 +627,16 @@ class SystemCrudController extends AuthController
|
||||
|
||||
if ($info->make_path) {
|
||||
$errorFile = [];
|
||||
// 存库路径归一化校验后再删除(安全审计 C1:删收敛白名单根目录)
|
||||
$verifyData = [];
|
||||
foreach ($info->make_path as $key => $item) {
|
||||
$verifyData[$key] = $this->normalizeCrudPath((string)$item);
|
||||
}
|
||||
if (!$this->crudVerifyPath($verifyData)) {
|
||||
throw new \RuntimeException('生成文件路径不合法');
|
||||
}
|
||||
foreach ($info->make_path as $key => $item) {
|
||||
$item = $verifyData[$key];
|
||||
if (in_array($key, ['pages', 'router', 'api'])) {
|
||||
$item = Make::adminTemplatePath() . $item;
|
||||
} else {
|
||||
|
||||
@ -73,13 +73,8 @@ class NoticeLogController extends AuthController
|
||||
if (!$id) {
|
||||
return app('json')->fail('参数错误');
|
||||
}
|
||||
$info = $this->services->getDetail($id);
|
||||
if (empty($info)) {
|
||||
return app('json')->fail('记录不存在');
|
||||
}
|
||||
|
||||
// 日志只有渲染后的内容,没有原始变量;重新执行会发送残缺信息。
|
||||
return app('json')->fail('该日志未保存原始变量,无法安全重试');
|
||||
$this->services->retry($id);
|
||||
return app('json')->success('重试发送成功');
|
||||
}
|
||||
|
||||
public function clean()
|
||||
|
||||
@ -11,15 +11,11 @@
|
||||
namespace app\adminapi\controller\system;
|
||||
|
||||
|
||||
use app\services\product\product\StoreDescriptionServices;
|
||||
use app\services\product\product\StoreProductCateServices;
|
||||
use app\services\product\product\StoreProductCouponServices;
|
||||
use app\services\product\product\StoreProductReplyServices;
|
||||
use think\facade\App;
|
||||
use think\facade\Db;
|
||||
use think\facade\Env;
|
||||
use app\adminapi\controller\AuthController;
|
||||
use app\services\system\SystemClearServices;
|
||||
use app\services\product\CenterProductServices;
|
||||
use app\services\system\attachment\SystemAttachmentServices;
|
||||
|
||||
|
||||
/**
|
||||
@ -48,12 +44,40 @@ class SystemClearDataController extends AuthController
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一方法
|
||||
* @param $type
|
||||
* 危险清理统一门禁:开发模式 + 超管 + 登录密码
|
||||
* @return mixed|null 失败时返回 json 响应,通过返回 null
|
||||
*/
|
||||
protected function guardDangerousAction()
|
||||
{
|
||||
if (!Env::get('app_debug', false)) {
|
||||
return app('json')->fail('生产环境无法使用此功能,如需使用请修改.env文件中app_debug项为true');
|
||||
}
|
||||
if (!$this->adminInfo || (int)($this->adminInfo['level'] ?? -1) !== 0) {
|
||||
return app('json')->fail('仅超级管理员可以操作');
|
||||
}
|
||||
[$pwd] = $this->request->postMore([
|
||||
['pwd', ''],
|
||||
], true);
|
||||
if ($pwd === '' || $pwd === null) {
|
||||
return app('json')->fail('请输入登录密码');
|
||||
}
|
||||
$admin = Db::name('system_admin')->where('id', (int)$this->adminId)->where('is_del', 0)->find();
|
||||
if (!$admin || !password_verify((string)$pwd, (string)($admin['pwd'] ?? ''))) {
|
||||
return app('json')->fail('登录密码错误');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一清除入口
|
||||
* @param string $type
|
||||
* @return mixed
|
||||
*/
|
||||
public function index($type)
|
||||
{
|
||||
if ($deny = $this->guardDangerousAction()) {
|
||||
return $deny;
|
||||
}
|
||||
switch ($type) {
|
||||
case 'temp':
|
||||
return $this->userTemp();
|
||||
@ -88,10 +112,7 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function userTemp()
|
||||
{
|
||||
/** @var SystemAttachmentServices $services */
|
||||
$services = app()->make(SystemAttachmentServices::class);
|
||||
$services->clearTemporaryAttachments();
|
||||
$this->services->clearData(['qrcode'], true);
|
||||
$this->services->clearTempData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -99,15 +120,9 @@ class SystemClearDataController extends AuthController
|
||||
* 清除回收站商品
|
||||
* @return mixed
|
||||
*/
|
||||
public function recycleProduct($id = 0)
|
||||
public function recycleProduct()
|
||||
{
|
||||
/** @var CenterProductServices $product */
|
||||
$product = app()->make(CenterProductServices::class);
|
||||
$product->recycleProduct($id);
|
||||
|
||||
if ($id) {
|
||||
return true;
|
||||
}
|
||||
$this->services->clearRecycleProduct();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -117,59 +132,7 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function userRelevantData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'distribution_level_task_record',
|
||||
'member_card',
|
||||
'member_card_batch',
|
||||
'capital_flow',
|
||||
'delivery_service',
|
||||
'division_agent_apply',
|
||||
'luck_lottery_record',
|
||||
'offline_receipt',
|
||||
'offline_receipt_status',
|
||||
'member_order',
|
||||
'member_order_status',
|
||||
'qrcode',
|
||||
'sms_record',
|
||||
'store_bargain_user',
|
||||
'store_bargain_user_help',
|
||||
'store_cart',
|
||||
'store_coupon_issue_user',
|
||||
'store_coupon_user',
|
||||
'store_integral_order',
|
||||
'store_integral_order_status',
|
||||
'order_center',
|
||||
'order_economize',
|
||||
'store_order_invoice',
|
||||
'store_product_relation',
|
||||
'store_product_reply',
|
||||
'store_service',
|
||||
'store_service_feedback',
|
||||
'store_service_log',
|
||||
'store_service_record',
|
||||
'store_visit',
|
||||
'system_store_staff',
|
||||
'user',
|
||||
'user_address',
|
||||
'user_bill',
|
||||
'user_brokerage_frozen',
|
||||
'user_cancel',
|
||||
'user_enter',
|
||||
'user_friends',
|
||||
'user_group',
|
||||
'user_invoice',
|
||||
'user_label',
|
||||
'user_label_relation',
|
||||
'user_level',
|
||||
'user_money',
|
||||
'user_recharge',
|
||||
'user_search',
|
||||
'user_sign',
|
||||
'user_spread',
|
||||
'user_visit',
|
||||
'wechat_user',
|
||||
], true);
|
||||
$this->services->delDirAndFile('./public/uploads/store/comment');
|
||||
$this->services->clearUserRelevantData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -179,97 +142,7 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function storeData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'distribution_level_task',
|
||||
'distribution_level_task_record',
|
||||
'article',
|
||||
'article_category',
|
||||
'article_content',
|
||||
'auxiliary',
|
||||
'cache',
|
||||
'capital_flow',
|
||||
'category',
|
||||
'delivery_service',
|
||||
'division_agent_apply',
|
||||
'live_anchor',
|
||||
'live_goods',
|
||||
'live_room',
|
||||
'live_room_goods',
|
||||
'luck_lottery',
|
||||
'luck_lottery_record',
|
||||
'luck_prize',
|
||||
'member_card',
|
||||
'member_card_batch',
|
||||
'offline_receipt',
|
||||
'offline_receipt_status',
|
||||
'member_order',
|
||||
'member_order_status',
|
||||
'qrcode',
|
||||
'sms_record',
|
||||
'store_advance',
|
||||
'store_bargain',
|
||||
'store_bargain_user',
|
||||
'store_bargain_user_help',
|
||||
'store_cart',
|
||||
'store_category',
|
||||
'store_combination',
|
||||
'store_coupon_issue',
|
||||
'store_coupon_issue_user',
|
||||
'store_coupon_product',
|
||||
'store_coupon_user',
|
||||
'store_integral',
|
||||
'store_integral_order',
|
||||
'store_integral_order_status',
|
||||
'order_center',
|
||||
'order_economize',
|
||||
'store_order_invoice',
|
||||
'store_pink',
|
||||
'product_center',
|
||||
'store_product_cate',
|
||||
'store_product_coupon',
|
||||
'store_product_description',
|
||||
'store_product_log',
|
||||
'store_product_relation',
|
||||
'store_product_reply',
|
||||
'store_product_rule',
|
||||
'store_product_virtual',
|
||||
'store_seckill',
|
||||
'store_service',
|
||||
'store_service_feedback',
|
||||
'store_service_log',
|
||||
'store_service_record',
|
||||
'store_visit',
|
||||
'system_log',
|
||||
'system_store',
|
||||
'system_store_staff',
|
||||
'user',
|
||||
'user_address',
|
||||
'user_bill',
|
||||
'user_brokerage_frozen',
|
||||
'user_cancel',
|
||||
'user_enter',
|
||||
'user_friends',
|
||||
'user_group',
|
||||
'user_invoice',
|
||||
'user_label',
|
||||
'user_label_relation',
|
||||
'user_level',
|
||||
'user_money',
|
||||
'user_recharge',
|
||||
'user_search',
|
||||
'user_sign',
|
||||
'user_spread',
|
||||
'user_visit',
|
||||
'wechat_key',
|
||||
'wechat_media',
|
||||
'wechat_message',
|
||||
'wechat_news_category',
|
||||
'wechat_qrcode',
|
||||
'wechat_qrcode_cate',
|
||||
'wechat_qrcode_record',
|
||||
'wechat_reply',
|
||||
'wechat_user',
|
||||
], true);
|
||||
$this->services->clearStoreData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -279,7 +152,7 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function categoryData()
|
||||
{
|
||||
$this->services->clearData(['store_category'], true);
|
||||
$this->services->clearCategoryData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -289,19 +162,7 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function orderData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'offline_receipt',
|
||||
'offline_receipt_status',
|
||||
'member_order',
|
||||
'member_order_status',
|
||||
'store_cart',
|
||||
'store_integral_order',
|
||||
'store_integral_order_status',
|
||||
'order_center',
|
||||
'order_economize',
|
||||
'store_order_invoice',
|
||||
'store_pink',
|
||||
], true);
|
||||
$this->services->clearOrderData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -311,14 +172,7 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function kefuData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'store_service',
|
||||
'store_service_log',
|
||||
'store_service_record',
|
||||
'store_service_feedback',
|
||||
'store_service_speechcraft'
|
||||
], true);
|
||||
$this->services->delDirAndFile('./public/uploads/store/service');
|
||||
$this->services->clearKefuData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -328,18 +182,7 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function wechatData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'cache',
|
||||
'wechat_key',
|
||||
'wechat_media',
|
||||
'wechat_message',
|
||||
'wechat_news_category',
|
||||
'wechat_qrcode',
|
||||
'wechat_qrcode_cate',
|
||||
'wechat_qrcode_record',
|
||||
'wechat_reply'
|
||||
], true);
|
||||
$this->services->delDirAndFile('./public/uploads/wechat');
|
||||
$this->services->clearWechatData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
@ -349,48 +192,80 @@ class SystemClearDataController extends AuthController
|
||||
*/
|
||||
public function attachmentData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'system_attachment',
|
||||
'system_attachment_category'
|
||||
], true);
|
||||
$this->services->delDirAndFile('./public/uploads/');
|
||||
$this->services->clearAttachmentData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
//清除内容分类
|
||||
/**
|
||||
* 清除内容分类
|
||||
* @return mixed
|
||||
*/
|
||||
public function articleData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'article_category',
|
||||
'article',
|
||||
'article_content'
|
||||
], true);
|
||||
$this->services->clearArticleData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
//清除系统记录
|
||||
/**
|
||||
* 清除系统记录
|
||||
* @return mixed
|
||||
*/
|
||||
public function systemData()
|
||||
{
|
||||
$this->services->clearData([
|
||||
'system_log'
|
||||
], true);
|
||||
$this->services->clearSystemLogData();
|
||||
return app('json')->success('清除数据成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复出厂设置
|
||||
* @return mixed
|
||||
*/
|
||||
public function factoryReset()
|
||||
{
|
||||
if ($deny = $this->guardDangerousAction()) {
|
||||
return $deny;
|
||||
}
|
||||
$steps = $this->services->factoryReset((int)$this->adminId);
|
||||
return app('json')->success('恢复出厂成功,请重新登录', ['steps' => $steps, 'relogin' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前网站域名(用于更换域名弹窗默认原域名)
|
||||
* @return mixed
|
||||
*/
|
||||
public function getSiteUrl()
|
||||
{
|
||||
return app('json')->success(['site_url' => (string)sys_config('site_url')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换域名方法
|
||||
* @return mixed
|
||||
*/
|
||||
public function replaceSiteUrl()
|
||||
{
|
||||
list($url) = $this->request->postMore([
|
||||
['url', '']
|
||||
if ($deny = $this->guardDangerousAction()) {
|
||||
return $deny;
|
||||
}
|
||||
[$url, $oldUrl] = $this->request->postMore([
|
||||
['url', ''],
|
||||
['old_url', ''],
|
||||
], true);
|
||||
if (!$url)
|
||||
return app('json')->fail('请输入需要更换的域名');
|
||||
if (!verify_domain($url))
|
||||
return app('json')->fail('域名不合法');
|
||||
$this->services->replaceSiteUrl($url);
|
||||
$url = trim((string)$url);
|
||||
$oldUrl = trim((string)$oldUrl);
|
||||
if ($oldUrl === '') {
|
||||
return app('json')->fail('请输入要被替换的域名');
|
||||
}
|
||||
if ($url === '') {
|
||||
return app('json')->fail('请输入新的域名');
|
||||
}
|
||||
if (!verify_domain($oldUrl)) {
|
||||
return app('json')->fail('要被替换的域名不合法,格式为:http://或https://域名');
|
||||
}
|
||||
if (!verify_domain($url)) {
|
||||
return app('json')->fail('新域名不合法,格式为:http://或https://域名');
|
||||
}
|
||||
$this->services->replaceSiteUrl($url, $oldUrl);
|
||||
return app('json')->success('替换成功');
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,24 +135,45 @@ class SystemCrontabController extends AuthController
|
||||
*/
|
||||
function isSafePhpCode($code)
|
||||
{
|
||||
// 检查是否包含删除表,删除表数据,删除文件,修改文件内容以及后缀,执行命令等操作的关键词
|
||||
// 黑名单加固(安全审计 C7,eval 功能保留但强化前置过滤):
|
||||
// 1. 匹配改为大小写不敏感(原实现对 Delete/drop 等变体全部漏放)
|
||||
// 2. 补齐原缺失的高危关键词:文件写读删、命令执行体系、include/require、eval/assert、反引号、动态调用
|
||||
$dangerous_keywords = array(
|
||||
'delete',
|
||||
'destroy',
|
||||
'DROP TABLE',
|
||||
'DELETE FROM',
|
||||
'drop table',
|
||||
'delete from',
|
||||
'truncate table',
|
||||
'unlink(',
|
||||
'fwrite(',
|
||||
'file_put_contents(',
|
||||
'file_get_contents(',
|
||||
'fopen(',
|
||||
'fputs(',
|
||||
'shell_exec(',
|
||||
'exec(',
|
||||
'system(',
|
||||
'passthru('
|
||||
'passthru(',
|
||||
'popen(',
|
||||
'proc_open(',
|
||||
'eval(',
|
||||
'assert(',
|
||||
'create_function(',
|
||||
'base64_decode(',
|
||||
'include',
|
||||
'require',
|
||||
);
|
||||
$code = (string)$code;
|
||||
$lower = strtolower($code);
|
||||
foreach ($dangerous_keywords as $keyword) {
|
||||
if (strpos($code, $keyword) !== false) {
|
||||
if (strpos($lower, $keyword) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 反引号执行符单独检测(黑名单数组无法直写字符串)
|
||||
if (strpos($code, chr(96)) !== false) {
|
||||
return false;
|
||||
}
|
||||
return true; // 如果通过所有安全检查,返回 true
|
||||
}
|
||||
|
||||
|
||||
@ -118,6 +118,8 @@ Route::group('order', function () {
|
||||
Route::get('center/categories', [\app\adminapi\controller\order\OrderCenterController::class, 'categories'])->option(['real_name' => '订单中台分类列表']);
|
||||
//订单中台筛选选项
|
||||
Route::get('center/filter_options', [\app\adminapi\controller\order\OrderCenterController::class, 'filterOptions'])->option(['real_name' => '订单中台筛选选项']);
|
||||
//已取消但仍收款的订单,管理端点一次才原路退
|
||||
Route::post('center/canceled_refund', [\app\adminapi\controller\order\OrderCenterController::class, 'submitCanceledRefund'])->option(['real_name' => '取消订单原路退款']);
|
||||
})->middleware([
|
||||
\app\http\middleware\AllowOriginMiddleware::class,
|
||||
\app\adminapi\middleware\AdminAuthTokenMiddleware::class,
|
||||
|
||||
@ -25,23 +25,23 @@ foreach ($files as $file) {
|
||||
*/
|
||||
Route::group(function () {
|
||||
//升级程序
|
||||
Route::get('upgrade', [\app\adminapi\controller\UpgradeController::class, 'index']);
|
||||
Route::get('upgrade/run', [\app\adminapi\controller\UpgradeController::class, 'upgrade']);
|
||||
Route::get('upgrade', [\app\adminapi\controller\UpgradeController::class, 'index'])->option(['real_name' => '升级程序入口']);
|
||||
Route::get('upgrade/run', [\app\adminapi\controller\UpgradeController::class, 'upgrade'])->option(['real_name' => '执行升级程序']);
|
||||
//用户名密码登录
|
||||
Route::post('login', [\app\adminapi\controller\LoginController::class, 'login'])->name('AdminLogin')->option(['real_name' => '下载表备份记录']);
|
||||
Route::post('login', [\app\adminapi\controller\LoginController::class, 'login'])->name('AdminLogin')->option(['real_name' => '管理员登录']);
|
||||
//后台登录页面数据
|
||||
Route::get('login/info', [\app\adminapi\controller\LoginController::class, 'info'])->option(['real_name' => '登录信息']);
|
||||
//验证码
|
||||
Route::get('captcha_pro', [\app\adminapi\controller\LoginController::class, 'captcha'])->name('')->option(['real_name' => '获取验证码']);
|
||||
//获取验证码
|
||||
Route::get('ajcaptcha', [\app\adminapi\controller\LoginController::class, 'ajcaptcha'])->name('ajcaptcha')->option(['real_name' => '获取验证码']);
|
||||
Route::get('ajcaptcha', [\app\adminapi\controller\LoginController::class, 'ajcaptcha'])->name('ajcaptcha')->option(['real_name' => '获取行为验证码']);
|
||||
//一次验证
|
||||
Route::post('ajcheck', [\app\adminapi\controller\LoginController::class, 'ajcheck'])->name('ajcheck')->option(['real_name' => '一次验证']);
|
||||
Route::post('ajcheck', [\app\adminapi\controller\LoginController::class, 'ajcheck'])->name('ajcheck')->option(['real_name' => '行为验证码校验']);
|
||||
//获取客服数据
|
||||
Route::get('get_workerman_url', [\app\adminapi\controller\PublicController::class, 'getWorkerManUrl'])->option(['real_name' => '获取客服数据']);
|
||||
//扫码上传图片
|
||||
Route::post('image/scan_upload', [\app\adminapi\controller\PublicController::class, 'scanUpload'])->option(['real_name' => '扫码上传图片']);
|
||||
Route::get('custom_admin_js', [\app\adminapi\controller\PublicController::class, 'customAdminJs'])->option(['real_name' => '测试地址']);
|
||||
Route::get('custom_admin_js', [\app\adminapi\controller\PublicController::class, 'customAdminJs'])->option(['real_name' => '获取自定义后台JS']);
|
||||
|
||||
})->middleware(AllowOriginMiddleware::class)->option(['mark' => 'login', 'mark_name' => '登录相关']);
|
||||
|
||||
|
||||
@ -136,6 +136,7 @@ Route::group('setting', function () {
|
||||
'index' => '获取组合数据列表',
|
||||
'create' => '获取组合数据表单',
|
||||
'save' => '保存组合数据',
|
||||
'read' => '获取组合数据详情',
|
||||
'edit' => '获取修改组合数据表单',
|
||||
'update' => '修改组合数据',
|
||||
'delete' => '删除组合数据'
|
||||
|
||||
@ -86,13 +86,17 @@ Route::group('system', function () {
|
||||
|
||||
/** 数据清除 */
|
||||
Route::group(function () {
|
||||
//清除用户数据
|
||||
Route::get('clear/:type', [\app\adminapi\controller\system\SystemClearDataController::class, 'index'])->option(['real_name' => '清除用户数据']);
|
||||
//清除数据(须 POST:app_debug + 超管 + 登录密码)
|
||||
Route::post('clear/:type', [\app\adminapi\controller\system\SystemClearDataController::class, 'index'])->option(['real_name' => '清除数据']);
|
||||
//清除缓存
|
||||
Route::get('refresh_cache/cache', [\app\adminapi\controller\system\ClearController::class, 'refreshCache'])->option(['real_name' => '清除系统缓存']);
|
||||
Route::get('refresh_cache/log', [\app\adminapi\controller\system\ClearController::class, 'deleteLog'])->option(['real_name' => '清除系统日志']);
|
||||
//当前网站域名(更换域名弹窗默认值)
|
||||
Route::get('replace_site_url', [\app\adminapi\controller\system\SystemClearDataController::class, 'getSiteUrl'])->option(['real_name' => '获取当前网站域名']);
|
||||
//域名替换接口
|
||||
Route::post('replace_site_url', [\app\adminapi\controller\system\SystemClearDataController::class, 'replaceSiteUrl'])->option(['real_name' => '域名替换']);
|
||||
//恢复出厂设置(须 app_debug + 超管)
|
||||
Route::post('factory_reset', [\app\adminapi\controller\system\SystemClearDataController::class, 'factoryReset'])->option(['real_name' => '恢复出厂设置']);
|
||||
//获取APP版本列表
|
||||
Route::get('version_list', [\app\adminapi\controller\system\AppVersionController::class, 'list'])->option(['real_name' => '获取APP版本列表']);
|
||||
//添加版本信息
|
||||
|
||||
@ -143,7 +143,7 @@ class AuthController
|
||||
if (!$verifyCode) {
|
||||
return app('json')->fail('请先获取验证码');
|
||||
}
|
||||
if (substr($verifyCode, 0, 6) != $captcha) {
|
||||
if (!is_string($captcha) || !hash_equals(substr((string)$verifyCode, 0, 6), $captcha)) {
|
||||
return app('json')->fail('验证码错误');
|
||||
}
|
||||
if (md5($password) == md5('123456')) {
|
||||
@ -155,6 +155,8 @@ class AuthController
|
||||
$registerStatus = $this->services->register($account, $password, $spread, $user_type);
|
||||
|
||||
if ($registerStatus) {
|
||||
// 验证码校验通过即销毁,防止有效期内被重复用于注册/改密(安全审计 A6)
|
||||
CacheService::delete('code_' . $account);
|
||||
return app('json')->success('注册成功');
|
||||
}
|
||||
return app('json')->fail('注册失败');
|
||||
|
||||
@ -70,24 +70,6 @@ class CrontabController
|
||||
event('ActivityDataProxy', ['mode' => 'combination', 'sub_service' => 'pink', 'action' => 'statusPink', 'args' => []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新直播商品状态
|
||||
*/
|
||||
public function syncGoodStatus()
|
||||
{
|
||||
//通过事件代理同步直播商品状态,避免 app 层直接引用 modules
|
||||
event('ChannelServiceProxy', ['endpoint' => 'wechat_mini', 'service' => 'LiveGoodsServices', 'action' => 'syncGoodStatus', 'args' => []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新直播间状态
|
||||
*/
|
||||
public function syncRoomStatus()
|
||||
{
|
||||
//通过事件代理同步直播间状态,避免 app 层直接引用 modules
|
||||
event('ChannelServiceProxy', ['endpoint' => 'wechat_mini', 'service' => 'LiveRoomServices', 'action' => 'syncRoomStatus', 'args' => []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动收货
|
||||
*/
|
||||
|
||||
@ -247,13 +247,26 @@ class LoginController
|
||||
$verifyCode = CacheService::get('code_' . $account);
|
||||
if (!$verifyCode)
|
||||
return app('json')->fail('请先获取验证码');
|
||||
$verifyCode = substr($verifyCode, 0, 6);
|
||||
if ($verifyCode != $captcha)
|
||||
$verifyCode = substr((string)$verifyCode, 0, 6);
|
||||
if (!is_string($captcha) || !hash_equals($verifyCode, $captcha)) {
|
||||
// 验证码比对失败计数:连续错 5 次销毁验证码,防止 5 分钟有效期内被枚举(安全审计 A6)
|
||||
$failKey = 'code_fail_' . $account;
|
||||
$failNum = (int)CacheService::get($failKey, 0) + 1;
|
||||
if ($failNum >= 5) {
|
||||
CacheService::delete($failKey);
|
||||
CacheService::delete('code_' . $account);
|
||||
return app('json')->fail('验证码错误次数过多,请重新获取');
|
||||
}
|
||||
CacheService::set($failKey, $failNum, 300);
|
||||
return app('json')->fail('验证码错误');
|
||||
}
|
||||
if (md5($password) == md5('123456')) return app('json')->fail('密码太过简单,请输入较为复杂的密码');
|
||||
|
||||
$registerStatus = $this->services->register($account, $password, $spread, 'h5');
|
||||
if ($registerStatus) {
|
||||
// 验证码校验通过即销毁,防止有效期内被重复用于注册/改密(安全审计 A6)
|
||||
CacheService::delete('code_' . $account);
|
||||
CacheService::delete('code_fail_' . $account);
|
||||
return app('json')->success('注册成功');
|
||||
}
|
||||
return app('json')->fail('注册失败');
|
||||
@ -281,13 +294,27 @@ class LoginController
|
||||
$verifyCode = CacheService::get('code_' . $account);
|
||||
if (!$verifyCode)
|
||||
return app('json')->fail('请先获取验证码');
|
||||
$verifyCode = substr($verifyCode, 0, 6);
|
||||
if ($verifyCode != $captcha) {
|
||||
$verifyCode = substr((string)$verifyCode, 0, 6);
|
||||
if (!is_string($captcha) || !hash_equals($verifyCode, $captcha)) {
|
||||
// 验证码比对失败计数:连续错 5 次销毁验证码,防止 5 分钟有效期内被枚举(安全审计 A6)
|
||||
$failKey = 'code_fail_' . $account;
|
||||
$failNum = (int)CacheService::get($failKey, 0) + 1;
|
||||
if ($failNum >= 5) {
|
||||
CacheService::delete($failKey);
|
||||
CacheService::delete('code_' . $account);
|
||||
return app('json')->fail('验证码错误次数过多,请重新获取');
|
||||
}
|
||||
CacheService::set($failKey, $failNum, 300);
|
||||
return app('json')->fail('验证码错误');
|
||||
}
|
||||
if ($password == '123456') return app('json')->fail('密码太过简单,请输入较为复杂的密码');
|
||||
$resetStatus = $this->services->reset($account, $password);
|
||||
if ($resetStatus) return app('json')->success('修改成功');
|
||||
if ($resetStatus) {
|
||||
// 验证码校验通过即销毁,防止有效期内被重复用于注册/改密(安全审计 A6)
|
||||
CacheService::delete('code_' . $account);
|
||||
CacheService::delete('code_fail_' . $account);
|
||||
return app('json')->success('修改成功');
|
||||
}
|
||||
return app('json')->fail('修改失败');
|
||||
}
|
||||
|
||||
@ -317,8 +344,8 @@ class LoginController
|
||||
$verifyCode = CacheService::get('code_' . $phone);
|
||||
if (!$verifyCode)
|
||||
return app('json')->fail('请先获取验证码');
|
||||
$verifyCode = substr($verifyCode, 0, 6);
|
||||
if ($verifyCode != $captcha) {
|
||||
$verifyCode = substr((string)$verifyCode, 0, 6);
|
||||
if (!is_string($captcha) || !hash_equals($verifyCode, $captcha)) {
|
||||
return app('json')->fail('验证码错误');
|
||||
}
|
||||
$user_type = $request->getFromType() ? $request->getFromType() : 'h5';
|
||||
@ -385,8 +412,8 @@ class LoginController
|
||||
$verifyCode = CacheService::get('code_' . $phone);
|
||||
if (!$verifyCode)
|
||||
return app('json')->fail('请先获取验证码');
|
||||
$verifyCode = substr($verifyCode, 0, 6);
|
||||
if ($verifyCode != $captcha) {
|
||||
$verifyCode = substr((string)$verifyCode, 0, 6);
|
||||
if (!is_string($captcha) || !hash_equals($verifyCode, $captcha)) {
|
||||
return app('json')->fail('验证码错误');
|
||||
}
|
||||
$re = $this->services->bindind_phone($phone, $key);
|
||||
@ -424,14 +451,25 @@ class LoginController
|
||||
$verifyCode = CacheService::get('code_' . $phone);
|
||||
if (!$verifyCode)
|
||||
return app('json')->fail('请先获取验证码');
|
||||
$verifyCode = substr($verifyCode, 0, 6);
|
||||
if ($verifyCode != $captcha)
|
||||
$verifyCode = substr((string)$verifyCode, 0, 6);
|
||||
if (!is_string($captcha) || !hash_equals($verifyCode, $captcha))
|
||||
return app('json')->fail('验证码错误');
|
||||
// 验证码通过后留存服务端凭证:step=1 合并确认必须凭此放行,防跳过短信验证(安全审计 B6.5)
|
||||
CacheService::set('bind_confirm_' . (int)$request->uid(), $phone, 300);
|
||||
} else {
|
||||
// step=1(合并既有 H5 账号)必须先经 step=0 的短信验证,凭证与手机号一致才放行
|
||||
$confirmedPhone = CacheService::get('bind_confirm_' . (int)$request->uid());
|
||||
if (!$confirmedPhone || $confirmedPhone !== $phone) {
|
||||
return app('json')->fail('请先完成短信验证');
|
||||
}
|
||||
}
|
||||
$uid = (int)$request->uid();
|
||||
$re = $this->services->userBindindPhone($uid, $phone, $step);
|
||||
if ($re) {
|
||||
CacheService::delete('code_' . $phone);
|
||||
if ($step) {
|
||||
CacheService::delete('bind_confirm_' . $uid);
|
||||
}
|
||||
return app('json')->success($re['msg'] ?? '绑定成功', $re['data'] ?? []);
|
||||
} else
|
||||
return app('json')->fail('绑定失败');
|
||||
@ -454,8 +492,8 @@ class LoginController
|
||||
$verifyCode = CacheService::get('code_' . $phone);
|
||||
if (!$verifyCode)
|
||||
return app('json')->fail('请先获取验证码');
|
||||
$verifyCode = substr($verifyCode, 0, 6);
|
||||
if ($verifyCode != $captcha)
|
||||
$verifyCode = substr((string)$verifyCode, 0, 6);
|
||||
if (!is_string($captcha) || !hash_equals($verifyCode, $captcha))
|
||||
return app('json')->fail('验证码错误');
|
||||
$uid = (int)$request->uid();
|
||||
$re = $this->services->updateBindindPhone($uid, $phone);
|
||||
@ -485,11 +523,8 @@ class LoginController
|
||||
}
|
||||
|
||||
/**
|
||||
* apple快捷登陆
|
||||
*
|
||||
* @deprecated 请使用 POST /api/auth/login(channel=wechat_mp, login_type=app_auth)
|
||||
* 旧入口内部已转发到统一逻辑:通过 ChannelServiceProxy 调用 wechat_mp 的 appAuth
|
||||
* 登录成功后由 LoginServices::createToken 触发 UserLogin 事件
|
||||
* Apple 快捷登录
|
||||
* 校验 identityToken(Apple JWKS 验签),用 JWT sub 作为 openId;不信任客户端自报 openId
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
@ -498,12 +533,18 @@ class LoginController
|
||||
*/
|
||||
public function appleLogin(Request $request)
|
||||
{
|
||||
[$openId, $phone, $email, $captcha] = $request->postMore([
|
||||
['openId', ''],
|
||||
[$identityToken, $phone, $email, $captcha] = $request->postMore([
|
||||
['identityToken', ''],
|
||||
['phone', ''],
|
||||
['email', ''],
|
||||
['captcha', '']
|
||||
], true);
|
||||
// 必须用 Apple identityToken 验签后的 sub,禁止信任客户端自报 openId
|
||||
$appleIdentity = $this->services->verifyAppleIdentityToken((string)$identityToken);
|
||||
$openId = $appleIdentity['openId'];
|
||||
if ($appleIdentity['email'] !== '') {
|
||||
$email = $appleIdentity['email'];
|
||||
}
|
||||
if ($phone) {
|
||||
if (!$captcha) {
|
||||
return app('json')->fail('请输入验证码');
|
||||
@ -512,16 +553,12 @@ class LoginController
|
||||
$verifyCode = CacheService::get('code_' . $phone);
|
||||
if (!$verifyCode)
|
||||
return app('json')->fail('请先获取验证码');
|
||||
$verifyCode = substr($verifyCode, 0, 6);
|
||||
if ($verifyCode != $captcha) {
|
||||
$verifyCode = substr((string)$verifyCode, 0, 6);
|
||||
if (!is_string($captcha) || !hash_equals($verifyCode, $captcha)) {
|
||||
CacheService::delete('code_' . $phone);
|
||||
return app('json')->fail('验证码错误');
|
||||
}
|
||||
} else {
|
||||
if (!$openId) {
|
||||
return app('json')->fail('参数错误');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($email === '') $email = substr(md5($openId), 0, 12);
|
||||
$userInfo = [
|
||||
'openId' => $openId,
|
||||
@ -533,6 +570,8 @@ class LoginController
|
||||
$results = event('ChannelServiceProxy', ['endpoint' => 'wechat_mp', 'service' => 'WechatServices', 'action' => 'appAuth', 'args' => [$userInfo, $phone, 'apple']]);
|
||||
$token = proxy_event_result($results, null);
|
||||
if ($token) {
|
||||
// 验证码校验已通过,登录成功即销毁(安全审计 A6)
|
||||
CacheService::delete('code_' . $phone);
|
||||
return app('json')->success('登录成功', $token);
|
||||
} else if ($token === false) {
|
||||
return app('json')->success('登录成功', ['isbind' => true]);
|
||||
|
||||
@ -13,7 +13,7 @@ namespace app\api\controller\v1;
|
||||
|
||||
|
||||
use app\Request;
|
||||
use app\services\PlatformDetector;
|
||||
use app\services\channel\PlatformDetector;
|
||||
use app\services\driver\pay\PayChannelServices;
|
||||
use crmeb\services\pay\Pay;
|
||||
|
||||
|
||||
@ -327,10 +327,14 @@ class PublicController
|
||||
if ($domainArr && (($imageUrl && !in_array($imageUrlHost, $domainArr)) || ($codeUrl && !in_array($codeUrlHost, $domainArr)))) {
|
||||
return app('json')->success(['code' => false, 'image' => false]);
|
||||
}
|
||||
if ($imageUrl !== '' && !preg_match('/.*(\.png|\.jpg|\.jpeg|\.gif)$/', $imageUrl) && strpos(strtolower($imageUrl), "phar://") !== false) {
|
||||
// 正向校验(安全审计 E2):拒绝危险 stream wrapper(phar/zip/data/file 等)+ 图片后缀白名单,
|
||||
// 相对路径与 http/https 均兼容
|
||||
if ($imageUrl !== '' && (in_array(strtolower((string)parse_url($imageUrl, PHP_URL_SCHEME)), ['phar', 'zip', 'data', 'file', 'ftp', 'php'], true)
|
||||
|| !preg_match('/\.(png|jpg|jpeg|gif)$/i', $imageUrl))) {
|
||||
return app('json')->success(['code' => false, 'image' => false]);
|
||||
}
|
||||
if ($codeUrl !== '' && !(preg_match('/.*(\.png|\.jpg|\.jpeg|\.gif)$/', $codeUrl) || strpos($codeUrl, 'https://mp.weixin.qq.com/cgi-bin/showqrcode') !== false) && strpos(strtolower($codeUrl), "phar://") !== false) {
|
||||
if ($codeUrl !== '' && !(preg_match('/\.(png|jpg|jpeg|gif)$/i', $codeUrl)
|
||||
|| strpos($codeUrl, 'https://mp.weixin.qq.com/cgi-bin/showqrcode') === 0)) {
|
||||
return app('json')->success(['code' => false, 'image' => false]);
|
||||
}
|
||||
try {
|
||||
@ -771,25 +775,6 @@ class PublicController
|
||||
return app('json')->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序跳转链接接口
|
||||
* @param $id
|
||||
* @author wuhaotian
|
||||
* @email 442384644@qq.com
|
||||
* @date 2024/2/26
|
||||
*/
|
||||
public function getSchemeUrl($id)
|
||||
{
|
||||
//通过事件代理查询小程序方案URL,避免 app 层直接引用 modules
|
||||
$results = event('ChannelServiceProxy', ['endpoint' => 'wechat_mini', 'service' => 'RoutineSchemeServices', 'action' => 'value', 'args' => [$id, 'url']]);
|
||||
$url = proxy_event_result($results, '');
|
||||
if ($url) {
|
||||
echo '<script>window.location.href="' . $url . '";</script>';
|
||||
} else {
|
||||
echo '<h1>未找到跳转路径</h1>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信服务商支付
|
||||
* @param Request $request
|
||||
@ -808,6 +793,13 @@ class PublicController
|
||||
['out_trade_no', ''],
|
||||
['check_code', ''],
|
||||
], true);
|
||||
// 免登录协议接口无法强鉴权,以限流防 out_trade_no 批量枚举(安全审计 B2)
|
||||
$rateKey = 'spr_' . md5($request->ip() . '|' . $out_trade_no);
|
||||
$rateNum = (int)CacheService::get($rateKey, 0);
|
||||
if ($rateNum >= 30) {
|
||||
throw new ApiException('请求过于频繁,请稍后再试');
|
||||
}
|
||||
CacheService::set($rateKey, $rateNum + 1, 60);
|
||||
$data['site_name'] = sys_config('site_name'); //网站名称
|
||||
$data['site_url'] = sys_config('site_url'); //网站地址
|
||||
$data['site_logo'] = sys_config('wap_login_logo'); //移动端登录logo
|
||||
|
||||
@ -131,7 +131,8 @@ class NoticeController
|
||||
public function detail(Request $request, int $id)
|
||||
{
|
||||
$uid = (int) $request->uid();
|
||||
$result = $this->noticeService->getDetail($id);
|
||||
// 带当前 uid 走可见性校验:未发布/未到发送时间/过期/他人定向消息一律不可读(安全审计 B6.1)
|
||||
$result = $this->noticeService->getDetail($id, $uid);
|
||||
if (!empty($result)) {
|
||||
$this->noticeService->markAsRead($id, $uid);
|
||||
}
|
||||
|
||||
@ -397,6 +397,16 @@ class CenterOrderController
|
||||
}
|
||||
$productType = $productTypes[0];
|
||||
|
||||
// 秒杀等活动走同一条确认,但不使用会员价、优惠券、积分和运费优惠
|
||||
/** @var TradeModeRegistry $tradeModeRegistry */
|
||||
$tradeModeRegistry = app()->make(TradeModeRegistry::class);
|
||||
[$activityType, $activityId] = $tradeModeRegistry->primaryCartActivity($validCartInfo);
|
||||
$allowDiscount = $tradeModeRegistry->orderAllowsDiscount($validCartInfo);
|
||||
if ($allowDiscount || $activityType === '' || !$tradeModeRegistry->has($activityType)) {
|
||||
$activityType = 'normal';
|
||||
$activityId = 0;
|
||||
}
|
||||
|
||||
// 计算价格组(含运费模板运费、会员运费折扣),与旧版确认页结构一致
|
||||
/** @var OrderComputedServices $computedServices */
|
||||
$computedServices = app()->make(OrderComputedServices::class);
|
||||
@ -408,6 +418,10 @@ class CenterOrderController
|
||||
if (is_object($userInfo)) $userInfo = $userInfo->toArray();
|
||||
$priceGroup = $computedServices->getOrderPriceGroup($storeFreePostage, $validCartInfo, $addr, $userInfo, $shippingType, $isGift);
|
||||
$validCartInfo = $priceGroup['cartInfo'] ?? $validCartInfo;
|
||||
if (!$allowDiscount) {
|
||||
$priceGroup['levelPrice'] = 0;
|
||||
$priceGroup['memberPrice'] = 0;
|
||||
}
|
||||
|
||||
// 组装与单品直购模式一致的 priceData,供 computed/create 读取
|
||||
// 送礼订单:应付价 = 商品总价 + 礼品附加费(无运费)
|
||||
@ -438,8 +452,8 @@ class CenterOrderController
|
||||
$orderServices = app()->make(OrderCenterUserServices::class);
|
||||
$orderKey = $orderServices->cacheOrderInfo($uid, $validCartInfo, $priceData, [
|
||||
'product_type' => $productType,
|
||||
'activity_type' => 'normal',
|
||||
'activity_id' => 0,
|
||||
'activity_type' => $activityType,
|
||||
'activity_id' => $activityId,
|
||||
'order_mode' => 'cart',
|
||||
'cart_ids' => $cartId, // 下单成功后清除购物车
|
||||
'new' => $new, // 是否缓存key模式(立即购买)
|
||||
@ -467,12 +481,12 @@ class CenterOrderController
|
||||
$integralOpen = (float)$integralRatio > 0 || $usableIntegral > 0;
|
||||
|
||||
// 可用优惠券(无可用券时自动领取整单最优券并入列表,保证与 computed 的自动选券结果一致)
|
||||
$couponList = $this->buildConfirmCouponList(
|
||||
$couponList = $allowDiscount ? $this->buildConfirmCouponList(
|
||||
$uid,
|
||||
$cartGroup,
|
||||
false,
|
||||
method_exists($handler, 'getPricingPolicy') ? $handler->getPricingPolicy() : []
|
||||
);
|
||||
) : [];
|
||||
|
||||
// 支付渠道状态
|
||||
/** @var PayChannelServices $payChannelServices */
|
||||
@ -563,8 +577,8 @@ class CenterOrderController
|
||||
// 专用发票:须发票功能开启且专票开关开启
|
||||
'special_invoice' => (bool)sys_config('invoice_func_status', 0)
|
||||
&& (bool)sys_config('special_invoice_status', 0),
|
||||
'integral' => $integralOpen, // 积分抵扣
|
||||
'coupon' => !empty($couponList), // 优惠券
|
||||
'integral' => $allowDiscount && $integralOpen, // 积分抵扣
|
||||
'coupon' => $allowDiscount && !empty($couponList), // 优惠券
|
||||
'custom_form' => !empty($cartCustomForm), // 自定义表单(取主商品定义)
|
||||
'params_list' => false, // 商品参数(下单页不展示)
|
||||
'visitor_form' => !empty($cartVisitorFormConfig), // 游客信息表单(门票专属,取主商品定义)
|
||||
@ -576,8 +590,8 @@ class CenterOrderController
|
||||
$data = [
|
||||
'order_key' => $orderKey,// 订单缓存key
|
||||
'product_type' => $productType,// 商品类型
|
||||
'activity_type' => 'normal',// 活动类型
|
||||
'activity_id' => 0,// 活动ID
|
||||
'activity_type' => $activityType,// 活动类型
|
||||
'activity_id' => $activityId,// 活动ID
|
||||
'features' => $features,// 功能开关配置
|
||||
'cartInfo' => $validCartInfo,// 有效购物车商品(失效商品不参与合单展示与计价)
|
||||
'valid_count' => count($validCartInfo),// 有效商品数量
|
||||
@ -597,7 +611,7 @@ class CenterOrderController
|
||||
],
|
||||
'userInfo' => $userInfo,// 用户信息
|
||||
'usable_integral' => $usableIntegral,// 可用积分
|
||||
'integral_open' => $integralOpen,// 是否开启积分抵扣
|
||||
'integral_open' => $allowDiscount && $integralOpen,// 是否开启积分抵扣
|
||||
'integral_ratio' => $integralRatio,// 积分抵扣比例
|
||||
'integralRatio' => $integralRatio,// 兼容旧字段名
|
||||
'coupon_list' => $couponList,// 优惠券列表
|
||||
@ -653,14 +667,22 @@ class CenterOrderController
|
||||
if (!empty($couponList)) {
|
||||
return $couponList;
|
||||
}
|
||||
// 用户尚无可用券:自动领取整单最优优惠券,行结构与 getUsableCouponList 保持一致
|
||||
// 用户尚无可用券:自动领取整单最优优惠券;领取后重新拉取完整可用列表,避免弹窗只剩最优一张
|
||||
try {
|
||||
/** @var OrderComputedServices $computedServices */
|
||||
$computedServices = app()->make(OrderComputedServices::class);
|
||||
$autoCoupon = $computedServices->autoMatchCoupon($uid, $cartGroup['valid'] ?? [], true);
|
||||
$autoRow = $couponServices->getUsableCouponRow($uid, (int)($autoCoupon['coupon_user_id'] ?? 0));
|
||||
if ($autoRow) {
|
||||
$couponList[] = $autoRow;
|
||||
$autoCouponUserId = (int)($autoCoupon['coupon_user_id'] ?? 0);
|
||||
try {
|
||||
$couponList = $couponServices->getUsableCouponList($uid, $cartGroup);
|
||||
} catch (\Throwable $e) {
|
||||
$couponList = [];
|
||||
}
|
||||
if (empty($couponList) && $autoCouponUserId > 0) {
|
||||
$autoRow = $couponServices->getUsableCouponRow($uid, $autoCouponUserId);
|
||||
if ($autoRow) {
|
||||
$couponList[] = $autoRow;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 自动领券失败不影响确认页主流程
|
||||
@ -677,6 +699,8 @@ class CenterOrderController
|
||||
$uid = (int) request()->uid();
|
||||
$key = (string) request()->param('key', '');
|
||||
$couponId = (int) request()->param('coupon_id', 0);
|
||||
// 默认允许自动选券;前端用户手动取消后传 0,避免 coupon_id=0 再次被自动用上
|
||||
$allowAutoCoupon = (int) request()->param('allow_auto_coupon', 1) !== 0;
|
||||
$useIntegral = (bool) request()->param('use_integral', false);
|
||||
$addressId = (int) request()->param('address_id', 0);
|
||||
$deliveryType = $this->normalizeOrderDeliveryType((string) request()->param('delivery_type', ''));
|
||||
@ -707,6 +731,11 @@ class CenterOrderController
|
||||
$isActivityOrder = (($cartGroup['activity_type'] ?? 'normal') !== 'normal')
|
||||
&& (int)($cartGroup['activity_id'] ?? 0) > 0;
|
||||
$cartInfo = $cartGroup['cartInfo'] ?? [];
|
||||
if (!app()->make(TradeModeRegistry::class)->orderAllowsDiscount($cartInfo)) {
|
||||
$couponId = 0;
|
||||
$allowAutoCoupon = false;
|
||||
$useIntegral = false;
|
||||
}
|
||||
|
||||
// 重算金额必须使用当前用户上下文,积分抵扣和会员价格都依赖用户信息。
|
||||
/** @var UserServices $userServices */
|
||||
@ -810,7 +839,8 @@ class CenterOrderController
|
||||
}
|
||||
|
||||
// 用户未选券时自动领取并使用整单最优优惠券(折扣对应真实领取的券,与商品详情到手价口径一致;失败降级为无券)
|
||||
if (!$couponId && !$isActivityOrder && ($pricingPolicy['coupon'] ?? true)) {
|
||||
// allow_auto_coupon=0 表示用户已显式取消用券,不得再自动选中
|
||||
if (!$couponId && $allowAutoCoupon && !$isActivityOrder && ($pricingPolicy['coupon'] ?? true)) {
|
||||
/** @var OrderComputedServices $computedServices */
|
||||
$computedServices = app()->make(OrderComputedServices::class);
|
||||
$autoCoupon = $computedServices->autoMatchCoupon($uid, $cartInfo, true);
|
||||
@ -1007,6 +1037,8 @@ class CenterOrderController
|
||||
$activityId = (int) request()->param('activity_id', 0);
|
||||
$formData = request()->param('order_data/a', []);
|
||||
$couponId = (int) request()->param('coupon_id', 0);
|
||||
// 与 computed 同口径:用户手动取消用券后传 0,创建订单时不再自动选券
|
||||
$allowAutoCoupon = (int) request()->param('allow_auto_coupon', 1) !== 0;
|
||||
$formDeliveryType = $this->normalizeOrderDeliveryType((string)($formData['delivery_type'] ?? ''));
|
||||
$formShippingType = $formDeliveryType === 'pickup' ? 2 : ($formDeliveryType === 'door' ? 3 : 1);
|
||||
$addressId = (int)($formData['address_id'] ?? 0);
|
||||
@ -1014,6 +1046,7 @@ class CenterOrderController
|
||||
// 送礼订单:地址由领取人填写,下单阶段不选地址、不计运费
|
||||
$isGift = (int)($formData['is_gift'] ?? request()->param('is_gift', 0));
|
||||
$giftMark = trim((string)($formData['gift_mark'] ?? request()->param('gift_mark', '')));
|
||||
$giftClaimKey = $isGift == 1 ? bin2hex(random_bytes(16)) : '';
|
||||
$storeInfo = [];
|
||||
$validatedAddressInfo = [];
|
||||
$normalDeliveryValidated = false;
|
||||
@ -1053,10 +1086,17 @@ class CenterOrderController
|
||||
|| ($cacheMode === '' && (array_key_exists('cart_ids', $cartGroup) || array_key_exists('new', $cartGroup)));
|
||||
$activityType = (string)($cartGroup['activity_type'] ?? 'normal');
|
||||
$activityId = (int)($cartGroup['activity_id'] ?? 0);
|
||||
$isActivityOrder = $activityType !== 'normal' && $activityId > 0;
|
||||
|
||||
/** @var TradeModeRegistry $tradeModeRegistry */
|
||||
$tradeModeRegistry = app()->make(TradeModeRegistry::class);
|
||||
if (($activityType === '' || $activityType === 'normal' || $activityId <= 0)) {
|
||||
[$resolvedType, $resolvedId] = $tradeModeRegistry->primaryCartActivity($cartInfo);
|
||||
if ($resolvedType !== '' && $resolvedId > 0 && $tradeModeRegistry->has($resolvedType)
|
||||
&& !$tradeModeRegistry->get($resolvedType)->allowsOrderDiscount()) {
|
||||
$activityType = $resolvedType;
|
||||
$activityId = $resolvedId;
|
||||
}
|
||||
}
|
||||
$isActivityOrder = $activityType !== 'normal' && $activityId > 0;
|
||||
$tradeModeHandler = $tradeModeRegistry->get($activityType);
|
||||
|
||||
// 校验确认缓存中的商品类型,避免客户端修改 product_type 绕过 Handler。
|
||||
@ -1246,7 +1286,8 @@ class CenterOrderController
|
||||
$originalTotalPrice = $priceData['totalPrice'] ?? 0;
|
||||
|
||||
// 用户未选券时自动匹配整单最优优惠券(可领取的先领取,失败降级为无券;活动订单与关闭券能力的商品类型不参与)
|
||||
if (!$couponId && !$isActivityOrder) {
|
||||
// allow_auto_coupon=0 表示用户已显式取消用券,不得再自动选中
|
||||
if (!$couponId && $allowAutoCoupon && !$isActivityOrder) {
|
||||
$couponPolicyEnabled = true;
|
||||
$autoHandler = $registry->get($productType);
|
||||
if (method_exists($autoHandler, 'getPricingPolicy')) {
|
||||
@ -1531,7 +1572,7 @@ class CenterOrderController
|
||||
// 库存、产品扩展、中台订单须原子提交;产品钩子可能预留稀缺资源。
|
||||
\think\facade\Db::startTrans();
|
||||
$orderTransactionStarted = true;
|
||||
// 普通实物订单在创建时锁定库存。活动订单由活动 Handler 自己管理库存,不能重复扣减。
|
||||
// 普通实物订单在创建时锁定库存。活动订单由活动处理器在下单回调里占用库存。
|
||||
if (!$isActivityOrder) {
|
||||
foreach ($cartInfo as $cartItem) {
|
||||
$itemProductId = (int)($cartItem['product_id'] ?? $cartItem['productInfo']['id'] ?? 0);
|
||||
@ -1684,11 +1725,11 @@ class CenterOrderController
|
||||
'coupon_id' => (!$isActivityOrder && $couponId > 0) ? $couponId : 0,
|
||||
'activity_info' => $activityExtraInfo,
|
||||
] + ($isGift == 1 ? [
|
||||
// 送礼订单:留言与领取记录挂在 extra_info,领取时写入 gift.gift_uid
|
||||
// 送礼订单:留言与领取记录挂在 extra_info;gift_key 为不可猜测领取令牌
|
||||
'gift_mark' => $giftMark,
|
||||
'gift' => [
|
||||
'gift_uid' => 0,
|
||||
'gift_key' => '',
|
||||
'gift_key' => $giftClaimKey,
|
||||
],
|
||||
] : []),
|
||||
'detail_route' => $handler->getOrderDetailRoute($orderNo),
|
||||
@ -1704,6 +1745,15 @@ class CenterOrderController
|
||||
// 查询失败不影响主流程(活动回调另有兜底)
|
||||
}
|
||||
|
||||
// 送礼令牌 → 订单 ID 缓存,供小程序码 scene 反查
|
||||
if ($isGift == 1 && $giftClaimKey !== '' && $centerOrderId > 0) {
|
||||
try {
|
||||
\crmeb\services\CacheService::set('gift_claim_key_' . $giftClaimKey, $centerOrderId, 86400 * 30);
|
||||
} catch (\Throwable $e) {
|
||||
// 缓存失败不影响下单;领取时回退 JSON 查询
|
||||
}
|
||||
}
|
||||
|
||||
// 下单勾选发票:同步创建开票申请记录,供后台发票列表与支付回写 is_pay 使用
|
||||
if (!empty($invoiceInfo) && $centerOrderId > 0) {
|
||||
try {
|
||||
@ -2268,11 +2318,12 @@ class CenterOrderController
|
||||
*/
|
||||
public function pay(YuePayServices $yuePayServices, OrderPayServices $payServices)
|
||||
{
|
||||
[$uni, $paytype, $quitUrl, $type] = request()->postMore([
|
||||
[$uni, $paytype, $quitUrl, $type, $friendKey] = request()->postMore([
|
||||
['uni', ''],
|
||||
['paytype', ''],
|
||||
['quitUrl', ''],
|
||||
['type', 0]
|
||||
['type', 0],
|
||||
['friend_key', ''],
|
||||
], true);
|
||||
|
||||
if (!$uni) return app('json')->fail('参数错误');
|
||||
@ -2291,13 +2342,41 @@ class CenterOrderController
|
||||
}
|
||||
|
||||
if (in_array($centerOrder['status_code'] ?? '', [OrderCenter::STATUS_CANCELED, OrderCenter::STATUS_DELETED], true)) {
|
||||
CacheService::delete('PAY_LOCK_' . $uni);
|
||||
return app('json')->fail('订单已经超过系统支付时间,无法支付,请重新下单');
|
||||
}
|
||||
if ($centerOrder['pay_status'] == 1) {
|
||||
CacheService::delete('PAY_LOCK_' . $uni);
|
||||
return app('json')->fail('订单已支付');
|
||||
}
|
||||
|
||||
$uid = $type == 1 ? (int)request()->uid() : $centerOrder['uid'];
|
||||
// 支付身份:本人支付须为订单归属人;好友代付须持有 friend_key(防任意用户代付/盗刷)
|
||||
$callerUid = (int)request()->uid();
|
||||
$type = (int)$type;
|
||||
if ($type === 1) {
|
||||
$extraInfo = $centerOrder['extra_info'] ?? [];
|
||||
if (is_string($extraInfo)) {
|
||||
$extraInfo = json_decode($extraInfo, true) ?: [];
|
||||
}
|
||||
$storedKey = (string)($extraInfo['friend_pay']['friend_key'] ?? '');
|
||||
if (!$centerServices->isValidGiftKeyFormat((string)$friendKey)
|
||||
|| !$centerServices->isValidGiftKeyFormat($storedKey)
|
||||
|| !hash_equals($storedKey, (string)$friendKey)) {
|
||||
CacheService::delete('PAY_LOCK_' . $uni);
|
||||
return app('json')->fail('无权支付该订单');
|
||||
}
|
||||
if ($callerUid === (int)($centerOrder['uid'] ?? 0)) {
|
||||
CacheService::delete('PAY_LOCK_' . $uni);
|
||||
return app('json')->fail('不能代付自己的订单');
|
||||
}
|
||||
$uid = $callerUid;
|
||||
} else {
|
||||
if ((int)($centerOrder['uid'] ?? 0) !== $callerUid) {
|
||||
CacheService::delete('PAY_LOCK_' . $uni);
|
||||
return app('json')->fail('订单不存在');
|
||||
}
|
||||
$uid = $callerUid;
|
||||
}
|
||||
$orderNo = $centerOrder['order_no'] ?? $uni;
|
||||
|
||||
// 构建兼容支付服务接口的订单数组
|
||||
@ -2339,55 +2418,14 @@ class CenterOrderController
|
||||
}
|
||||
|
||||
if (bcsub((string)$orderInfo['pay_price'], '0', 2) <= 0 || ($isActivityOrder && $isFreePay)) {
|
||||
// 活动订单在 0 元支付前先执行活动支付处理(如扣减积分)
|
||||
if ($isActivityOrder && $tradeModeHandler) {
|
||||
try {
|
||||
$extraInfo = $centerOrder['extra_info'] ?? [];
|
||||
$skuUnique = $extraInfo['sku_info']['unique'] ?? '';
|
||||
$tradeModeHandler->beforeOrderPay($orderNo, $activityId, $centerOrder);
|
||||
$tradeModeHandler->onOrderPay($orderNo, $activityId, array_merge($centerOrder, [
|
||||
'sku_unique' => $skuUnique,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
CacheService::delete('PAY_LOCK_' . $uni);
|
||||
return app('json')->status('pay_error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @var OrderSuccessServices $success */
|
||||
$success = app()->make(OrderSuccessServices::class);
|
||||
// 免费活动订单使用 free 支付方式记录,避免被记录为余额支付
|
||||
// 免费活动订单使用 free 支付方式记录,避免被记录为余额支付。活动钩子由 paySuccess 统一调用。
|
||||
$freePayType = $isActivityOrder && $isFreePay ? 'free' : PayServices::YUE_PAY;
|
||||
$payPriceStatus = $success->zeroYuanPayment($orderInfo, $uid, $freePayType);
|
||||
if ($payPriceStatus) {
|
||||
// 0 元支付成功后触发活动支付成功回调(如扣减活动库存)
|
||||
if ($isActivityOrder && $tradeModeHandler) {
|
||||
try {
|
||||
$extraInfo = $centerOrder['extra_info'] ?? [];
|
||||
$skuUnique = $extraInfo['sku_info']['unique'] ?? '';
|
||||
$tradeModeHandler->afterOrderPay($orderNo, $activityId, array_merge($centerOrder, [
|
||||
'sku_unique' => $skuUnique,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::warning('活动支付成功回调执行失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
return app('json')->status('success', '支付成功', ['order_id' => $orderNo, 'key' => $orderNo]);
|
||||
}
|
||||
|
||||
// 0 元支付失败时,触发活动支付失败回调回滚已扣减资源(如积分)
|
||||
if ($isActivityOrder && $tradeModeHandler) {
|
||||
try {
|
||||
$extraInfo = $centerOrder['extra_info'] ?? [];
|
||||
$skuUnique = $extraInfo['sku_info']['unique'] ?? '';
|
||||
$tradeModeHandler->onOrderPayFail($orderNo, $activityId, array_merge($centerOrder, [
|
||||
'sku_unique' => $skuUnique,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::warning('活动支付失败回调执行失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return app('json')->status('pay_error', '支付失败');
|
||||
}
|
||||
|
||||
@ -2438,15 +2476,21 @@ class CenterOrderController
|
||||
'yue_pay_status' => $payChannelServices->getChannelStatus('yue'),
|
||||
];
|
||||
|
||||
$uid = (int)request()->uid();
|
||||
$data['order_id'] = $orderNo;
|
||||
$data['pay_price'] = '0';
|
||||
$data['now_money'] = app()->make(UserServices::class)->value(['uid' => (int)request()->uid()], 'now_money');
|
||||
$data['now_money'] = app()->make(UserServices::class)->value(['uid' => $uid], 'now_money');
|
||||
|
||||
switch ($type) {
|
||||
case 'order':
|
||||
/** @var OrderCenterUserServices $centerServices */
|
||||
$centerServices = app()->make(OrderCenterUserServices::class);
|
||||
$order = $centerServices->getDetailByOrderNo($orderNo, false);
|
||||
// 仅订单归属人(或礼物领取人)可读收银台支付元数据,防 IDOR
|
||||
$ownedId = $centerServices->getOwnedOrderIdByNo((string)$orderNo, $uid);
|
||||
if ($ownedId === null) {
|
||||
return app('json')->fail('您支付的订单不存在');
|
||||
}
|
||||
$order = $centerServices->getDetail($ownedId, false);
|
||||
if (!$order) {
|
||||
return app('json')->fail('您支付的订单不存在');
|
||||
}
|
||||
@ -2625,8 +2669,14 @@ class CenterOrderController
|
||||
public function refundDetail($uni, $cartId = '')
|
||||
{
|
||||
if (!strlen(trim($uni))) return app('json')->fail('参数错误');
|
||||
$uid = (int)request()->uid();
|
||||
$orderCenterServices = app()->make(OrderCenterUserServices::class);
|
||||
$order = $orderCenterServices->getDetailByOrderNo($uni, false);
|
||||
// 归属校验:仅订单归属人或礼物领取人可查看(与 center/order/detail 一致,防 IDOR)
|
||||
$ownedId = $orderCenterServices->getOwnedOrderIdByNo((string)$uni, $uid);
|
||||
if ($ownedId === null) {
|
||||
return app('json')->fail('订单不存在');
|
||||
}
|
||||
$order = $orderCenterServices->getDetail($ownedId, false);
|
||||
if (!$order) return app('json')->fail('订单不存在');
|
||||
// 订单详情格式化(OrderOutServices 已中台化,tidyOrder 基于中台数据)
|
||||
$orderData = app()->make(OrderOutServices::class)->tidyOrder($order, true);
|
||||
@ -2866,7 +2916,8 @@ class CenterOrderController
|
||||
public function product()
|
||||
{
|
||||
[$unique] = request()->postMore([['unique', '']], true);
|
||||
$item = $this->findOrderItemBySkuUnique((string)$unique);
|
||||
// 评价页商品展示:必须限定当前用户可操作订单(下单人或礼物领取人),防凭 unique 扫他人行项
|
||||
$item = $this->findOrderItemBySkuUnique((string)$unique, (int)request()->uid());
|
||||
if (!$item) return app('json')->fail('商品不存在');
|
||||
// 订单号由辅助方法附带返回(_order_no)
|
||||
$cartProduct = [];
|
||||
@ -3063,16 +3114,21 @@ class CenterOrderController
|
||||
*/
|
||||
public function friendDetail()
|
||||
{
|
||||
[$orderId] = request()->getMore([
|
||||
['order_id', '']
|
||||
[$orderId, $friendKey] = request()->getMore([
|
||||
['order_id', ''],
|
||||
['friend_key', ''],
|
||||
], true);
|
||||
$orderId = (int) $orderId;
|
||||
if ($orderId <= 0) {
|
||||
return app('json')->fail('订单参数错误');
|
||||
}
|
||||
$orderCenterServices = app()->make(OrderCenterUserServices::class);
|
||||
$info = $orderCenterServices->getFriendDetail($orderId, (int) request()->uid());
|
||||
return app('json')->success(compact('info'));
|
||||
try {
|
||||
$orderCenterServices = app()->make(OrderCenterUserServices::class);
|
||||
$info = $orderCenterServices->getFriendDetail($orderId, (int) request()->uid(), (string)$friendKey);
|
||||
return app('json')->success(compact('info'));
|
||||
} catch (\Throwable $e) {
|
||||
return app('json')->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -3107,18 +3163,41 @@ class CenterOrderController
|
||||
}
|
||||
|
||||
/**
|
||||
* 礼品详情
|
||||
* 礼品详情(须带 gift_key,防止仅凭 oid 枚举劫持)
|
||||
* @param $oid
|
||||
* @return mixed
|
||||
*/
|
||||
public function giftDetail($oid)
|
||||
{
|
||||
if (!$oid) {
|
||||
$giftKey = (string)request()->param('gift_key', '');
|
||||
if (!$oid || $giftKey === '') {
|
||||
return app('json')->fail('缺少参数');
|
||||
}
|
||||
$orderCenterServices = app()->make(OrderCenterUserServices::class);
|
||||
// 路由参数为字符串,服务层签名要求 int
|
||||
return app('json')->success($orderCenterServices->giftDetail((int)$oid));
|
||||
try {
|
||||
$orderCenterServices = app()->make(OrderCenterUserServices::class);
|
||||
return app('json')->success($orderCenterServices->giftDetail((int)$oid, $giftKey));
|
||||
} catch (\Throwable $e) {
|
||||
return app('json')->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按礼物令牌获取礼品详情(小程序码 scene 仅含 token)
|
||||
* @param string $key
|
||||
* @return mixed
|
||||
*/
|
||||
public function giftByKey($key)
|
||||
{
|
||||
$giftKey = (string)$key;
|
||||
if ($giftKey === '') {
|
||||
return app('json')->fail('缺少参数');
|
||||
}
|
||||
try {
|
||||
$orderCenterServices = app()->make(OrderCenterUserServices::class);
|
||||
return app('json')->success($orderCenterServices->giftDetailByKey($giftKey));
|
||||
} catch (\Throwable $e) {
|
||||
return app('json')->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -82,11 +82,24 @@ class MessageSystemController
|
||||
['value', ''],
|
||||
['all', 0]
|
||||
]);
|
||||
$uid = (int)$request->uid();
|
||||
$field = (string)$data['key'];
|
||||
// 仅允许已读/删除,且必须作用于当前登录用户
|
||||
if (!in_array($field, ['look', 'is_del'], true)) {
|
||||
return app('json')->fail('不允许的操作');
|
||||
}
|
||||
$all = (int)$data['all'];
|
||||
if ($all === 1) {
|
||||
$this->services->update(['uid' => $request->uid()], [$data['key'] => $data['value']]);
|
||||
if ($field !== 'look') {
|
||||
return app('json')->fail('参数错误');
|
||||
}
|
||||
$this->services->update(['uid' => $uid], ['look' => 1]);
|
||||
} else {
|
||||
$this->services->update($data['id'], [$data['key'] => $data['value']]);
|
||||
$id = (int)$data['id'];
|
||||
if ($id <= 0) {
|
||||
return app('json')->fail('参数错误');
|
||||
}
|
||||
$this->services->updateForUser($uid, $id, [$field => $data['value']]);
|
||||
}
|
||||
return app('json')->success('设置成功');
|
||||
}
|
||||
|
||||
@ -327,58 +327,6 @@ class UserBillController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小程序二维码
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
* @throws \think\Exception
|
||||
* @throws \think\db\exception\DataNotFoundException
|
||||
* @throws \think\db\exception\DbException
|
||||
* @throws \think\db\exception\ModelNotFoundException
|
||||
*/
|
||||
public function getRoutineCode(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
/** @var SystemAttachmentServices $systemAttachment */
|
||||
$systemAttachment = app()->make(SystemAttachmentServices::class);
|
||||
//小程序
|
||||
$name = $user['uid'] . '_user_routine.jpg';
|
||||
$imageInfo = $systemAttachment->getInfo(['real_name' => $name, 'module_type' => 2]);
|
||||
//检测远程文件是否存在
|
||||
if (isset($imageInfo['att_dir']) && strstr($imageInfo['att_dir'], 'http') !== false && curl_file_exist($imageInfo['att_dir']) === false) {
|
||||
$imageInfo = null;
|
||||
$systemAttachment->delete(['name' => $name]);
|
||||
}
|
||||
$siteUrl = sys_config('site_url');
|
||||
/** @var QrcodeServices $qrCode */
|
||||
$qrCode = app()->make(QrcodeServices::class);
|
||||
if (!$imageInfo) {
|
||||
$resForever = $qrCode->qrCodeForever($user['uid'], 'spread', '', '');
|
||||
//通过事件代理获取小程序码,避免 app 层直接引用 modules
|
||||
$_mpResults = event('ChannelServiceProxy', ['endpoint' => 'wechat_mini', 'service' => 'MiniProgramService', 'action' => 'appCodeUnlimitService', 'args' => [$resForever->id, '', 280]]);
|
||||
$resCode = (is_array($_mpResults) && $_mpResults[0] !== null) ? $_mpResults[0] : null;
|
||||
if ($resCode) {
|
||||
$res = ['res' => $resCode, 'id' => $resForever->id];
|
||||
} else {
|
||||
$res = false;
|
||||
}
|
||||
if (!$res) return app('json')->fail('二维码生成失败');
|
||||
$upload = UploadService::init();
|
||||
$uploadRes = $upload->to('routine/spread/code')->validate()->setAuthThumb(false)->stream($res['res'], $name);
|
||||
if ($uploadRes === false) {
|
||||
return app('json')->fail($upload->getError());
|
||||
}
|
||||
$imageInfo = $upload->getUploadInfo();
|
||||
$imageInfo['image_type'] = upload_type();
|
||||
$systemAttachment->attachmentAdd($imageInfo['name'], $imageInfo['size'], $imageInfo['type'], $imageInfo['dir'], $imageInfo['thumb_path'], 1, $imageInfo['image_type'], $imageInfo['time'], 2);
|
||||
$qrCode->setQrcodeFind($res['id'], ['status' => 1, 'url_time' => time(), 'qrcode_url' => $imageInfo['dir']]);
|
||||
$urlCode = $imageInfo['dir'];
|
||||
} else $urlCode = $imageInfo['att_dir'];
|
||||
if ($imageInfo['image_type'] == 1) $urlCode = $siteUrl . $urlCode;
|
||||
return app('json')->success(['url' => $urlCode]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 积分记录
|
||||
* @param Request $request
|
||||
|
||||
60
backend/app/api/middleware/CrontabKeyMiddleware.php
Normal file
60
backend/app/api/middleware/CrontabKeyMiddleware.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: CRMEB Team <admin@crmeb.com>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\api\middleware;
|
||||
|
||||
use crmeb\exceptions\ApiException;
|
||||
use crmeb\interfaces\MiddlewareInterface;
|
||||
use crmeb\utils\JwtAuth;
|
||||
use think\facade\Env;
|
||||
use think\Request;
|
||||
|
||||
/**
|
||||
* 定时任务外部触发密钥校验中间件
|
||||
* /api/crontab/* 供服务器 crontab 免登录触发。
|
||||
* 优先使用独立 CRONTAB_KEY(与 JWT 的 APP_KEY 分离,降低一钥失陷面);
|
||||
* 未配置时回退 APP_KEY(兼容旧部署)。可选 CRONTAB_ALLOW_IPS 限制来源 IP。
|
||||
* Class CrontabKeyMiddleware
|
||||
* @package app\api\middleware
|
||||
*/
|
||||
class CrontabKeyMiddleware implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function handle(Request $request, \Closure $next)
|
||||
{
|
||||
$key = (string)($request->header('key', '') ?: $request->param('key', ''));
|
||||
// 独立定时任务密钥优先;未配置时回退 APP_KEY(请在生产配置 crontab.key)
|
||||
$expected = (string)Env::get('crontab.key', '');
|
||||
if ($expected === '') {
|
||||
$expected = JwtAuth::secretKey();
|
||||
}
|
||||
if ($key === '' || !hash_equals($expected, $key)) {
|
||||
throw new ApiException('定时任务调用密钥错误或缺失', [], 403);
|
||||
}
|
||||
|
||||
// 可选来源 IP 白名单(逗号分隔);配置后非白名单 IP 一律拒绝
|
||||
$allowIps = (string)Env::get('crontab.allow_ips', '');
|
||||
if ($allowIps !== '') {
|
||||
$ip = (string)$request->ip();
|
||||
$list = array_values(array_filter(array_map('trim', explode(',', $allowIps)), static fn($v) => $v !== ''));
|
||||
if ($list && !in_array($ip, $list, true)) {
|
||||
throw new ApiException('定时任务来源 IP 不允许', [], 403);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -52,8 +52,7 @@ Route::group(function () {
|
||||
Route::get('copyright', [\app\api\controller\v1\PublicController::class, 'copyright'])->option(['real_name' => '申请版权'])->option(['real_name' => '查询版权']);
|
||||
//商城基础配置汇总接口
|
||||
Route::get('basic_config', [\app\api\controller\v1\PublicController::class, 'getMallBasicConfig'])->option(['real_name' => '商城基础配置汇总接口']);
|
||||
//小程序跳转url接口
|
||||
Route::get('get_scheme_url/:id', [\app\api\controller\v1\PublicController::class, 'getSchemeUrl'])->option(['real_name' => '小程序跳转url接口']);
|
||||
// 小程序外链跳转已迁移至 modules/channel/wechat_mini/route/user.php
|
||||
//远程注册用户
|
||||
Route::get('remote_register', [\app\api\controller\v1\LoginController::class, 'remoteRegister'])->option(['real_name' => '远程注册用户']);
|
||||
|
||||
@ -164,7 +163,8 @@ Route::group(function () {
|
||||
Route::get('order/cashier/:orderNo/[:type]', [\app\api\controller\v1\order\CenterOrderController::class, 'cashier'])->name('orderCashier')->option(['real_name' => '订单收银台']); //订单收银台
|
||||
Route::get('order/friend_detail', [\app\api\controller\v1\order\CenterOrderController::class, 'friendDetail'])->name('friendDetail')->option(['real_name' => '代付详情']);//代付详情
|
||||
Route::post('order/receive_gift/:oid', [\app\api\controller\v1\order\CenterOrderController::class, 'receiveGift'])->name('receiveGift')->option(['real_name' => '领取礼物']);//领取礼物
|
||||
Route::get('order/gift_detail/:oid', [\app\api\controller\v1\order\CenterOrderController::class, 'giftDetail'])->name('giftDetail')->option(['real_name' => '礼品详情']); //礼品详情
|
||||
Route::get('order/gift_detail/:oid', [\app\api\controller\v1\order\CenterOrderController::class, 'giftDetail'])->name('giftDetail')->option(['real_name' => '礼品详情']); //礼品详情(须 gift_key)
|
||||
Route::get('order/gift_by_key/:key', [\app\api\controller\v1\order\CenterOrderController::class, 'giftByKey'])->name('giftByKey')->option(['real_name' => '按令牌礼品详情']); //小程序码 scene 仅 token
|
||||
|
||||
})->option(['mark' => 'order', 'mark_name' => '订单']);
|
||||
|
||||
@ -180,7 +180,7 @@ Route::group(function () {
|
||||
Route::get('user/spread_info', [\app\api\controller\v1\user\UserBillController::class, 'getSpreadInfo'])->name('getSpreadInfo')->option(['real_name' => '分享邀新海报素材']);//分享邀新海报素材
|
||||
Route::get('integral/list', [\app\api\controller\v1\user\UserBillController::class, 'integralList'])->name('integralList')->option(['real_name' => '积分记录']);//积分记录
|
||||
Route::get('user/money/list/:type', [\app\api\controller\v1\user\UserBillController::class, 'moneyList'])->name('userMoneyList')->option(['real_name' => '资金明细']);//资金明细
|
||||
Route::get('user/routine_code', [\app\api\controller\v1\user\UserBillController::class, 'getRoutineCode'])->name('getRoutineCode')->option(['real_name' => '小程序二维码']);//小程序二维码
|
||||
// 用户推广小程序码已迁移至 modules/channel/wechat_mini/route/user.php
|
||||
})->option(['mark' => 'division', 'mark_name' => '账单']);
|
||||
|
||||
|
||||
@ -281,7 +281,7 @@ Route::group(function () {
|
||||
Route::get('index', [\app\api\controller\v1\PublicController::class, 'index'])->name('index')->option(['real_name' => '首页']);//首页
|
||||
Route::get('site_config', [\app\api\controller\v1\PublicController::class, 'getSiteConfig'])->name('getSiteConfig')->option(['real_name' => '获取网站配置']);//获取网站配置
|
||||
//DIY接口
|
||||
Route::get('diy/get_diy/[:id]', [\app\api\controller\v1\PublicController::class, 'getDiy']);
|
||||
Route::get('diy/get_diy/[:id]', [\app\api\controller\v1\PublicController::class, 'getDiy'])->option(['real_name' => '获取DIY数据']);
|
||||
Route::get('home/products', [\app\api\controller\v1\PublicController::class, 'homeProductsList'])->name('homeProductsList')->option(['real_name' => '获取首页推荐不同类型商品的轮播图和商品']);//获取首页推荐不同类型商品的轮播图和商品
|
||||
|
||||
Route::get('theme_info/:type', [\app\api\controller\v1\PublicController::class, 'themeInfo'])->name('themeInfo')->option(['real_name' => '主题详情']);
|
||||
@ -410,10 +410,7 @@ Route::group(function () {
|
||||
Route::get('crontab/order_cancel', [\app\api\controller\v1\CrontabController::class, 'orderUnpaidCancel'])->name('orderUnpaidCancel')->option(['real_name' => '未支付自动取消订单']);
|
||||
//拼团到期订单处理
|
||||
Route::get('crontab/pink_expiration', [\app\api\controller\v1\CrontabController::class, 'pinkExpiration'])->name('pinkExpiration')->option(['real_name' => '拼团到期订单处理']);
|
||||
//更新直播商品状态
|
||||
Route::get('crontab/live_product_status', [\app\api\controller\v1\CrontabController::class, 'syncGoodStatus'])->name('syncGoodStatus')->option(['real_name' => '更新直播商品状态']);
|
||||
//更新直播间状态
|
||||
Route::get('crontab/live_room_status', [\app\api\controller\v1\CrontabController::class, 'syncRoomStatus'])->name('syncRoomStatus')->option(['real_name' => '更新直播间状态']);
|
||||
// 直播商品/直播间状态同步已迁移至 modules/channel/wechat_mini/route/user.php
|
||||
//自动收货
|
||||
Route::get('crontab/take_delivery', [\app\api\controller\v1\CrontabController::class, 'autoTakeOrder'])->name('autoTakeOrder')->option(['real_name' => '自动收货']);
|
||||
//查询预售到期商品自动下架
|
||||
@ -423,7 +420,7 @@ Route::group(function () {
|
||||
//清除昨日海报
|
||||
Route::get('crontab/clear_poster', [\app\api\controller\v1\CrontabController::class, 'emptyYesterdayAttachment'])->name('emptyYesterdayAttachment')->option(['real_name' => '清除昨日海报']);
|
||||
|
||||
})->option(['mark' => 'crontab', 'mark_name' => '定时任务']);
|
||||
})->middleware(\app\api\middleware\CrontabKeyMiddleware::class)->option(['mark' => 'crontab', 'mark_name' => '定时任务']);
|
||||
|
||||
})->middleware(\app\http\middleware\AllowOriginMiddleware::class)
|
||||
->middleware(\app\api\middleware\StationOpenMiddleware::class)
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\services\pay\PayRecordServices;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 恢复既有支付合同、退款和后置任务,不创建新的支付订单。
|
||||
*/
|
||||
class PaymentRecover extends Command
|
||||
{
|
||||
/**
|
||||
* 配置命令。
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('pay:recover')->setDescription('分批查询微信漏单并恢复退款及支付后置任务');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行恢复并输出脱敏汇总。
|
||||
* @param Input $input 输入
|
||||
* @param Output $output 输出
|
||||
* @return int 退出码
|
||||
*/
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
try {
|
||||
$result = app()->make(PayRecordServices::class)->recover();
|
||||
$output->writeln(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
return $result['failed'] ? 1 : 0;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('支付恢复失败,请确认支付记录迁移已执行。异常类型:' . get_class($e));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,7 @@ class Util extends Command
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('util')
|
||||
->addArgument('type', Argument::REQUIRED, '类型replace/route/apifox')
|
||||
->addArgument('type', Argument::REQUIRED, '类型replace/route/route_doc/apifox')
|
||||
->addOption('h', null, Option::VALUE_REQUIRED, '替换成当前域名')
|
||||
->addOption('u', null, Option::VALUE_REQUIRED, '替换的域名')
|
||||
->addOption('a', null, Option::VALUE_REQUIRED, '应用名')
|
||||
@ -61,6 +61,20 @@ class Util extends Command
|
||||
}
|
||||
app()->make(SystemRouteServices::class)->syncRoute($appName);
|
||||
break;
|
||||
case 'route_doc':
|
||||
$appName = $input->getOption('a') ?: 'adminapi';
|
||||
$apps = $appName === 'all' ? ['adminapi', 'api'] : [$appName];
|
||||
foreach ($apps as $app) {
|
||||
$result = app()->make(\app\services\system\SystemRouteDocBuilder::class)->build($app);
|
||||
$output->writeln(sprintf(
|
||||
'[%s] total=%d updated=%d skipped=%d',
|
||||
$app,
|
||||
$result['total'],
|
||||
$result['updated'],
|
||||
$result['skipped']
|
||||
));
|
||||
}
|
||||
break;
|
||||
case 'apifox':
|
||||
$filePath = $input->getOption('f');
|
||||
if (!$filePath) {
|
||||
|
||||
@ -638,6 +638,9 @@ if (!function_exists('image_to_base64')) {
|
||||
curl_setopt($curl, CURLOPT_ENCODING, 'gzip');
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $timeout);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
|
||||
// DNS 解析固定到前置校验的 IP,防解析与请求分离的 DNS 重绑定 TOCTOU(安全审计 E2)
|
||||
$resolvePort = (int)(parse_url($avatar, PHP_URL_PORT) ?: 80);
|
||||
curl_setopt($curl, CURLOPT_RESOLVE, [$url . ':' . $resolvePort . ':' . $ip]);
|
||||
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, FALSE);
|
||||
$data = curl_exec($curl);
|
||||
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
@ -665,9 +668,19 @@ if (!function_exists('put_image')) {
|
||||
if ($url === '') {
|
||||
return false;
|
||||
}
|
||||
// 仅允许 http/https,杜绝 phar/data 等 stream wrapper(安全审计 E2)
|
||||
if (!is_string($url) || !preg_match('#^https?://#i', $url)) {
|
||||
return false;
|
||||
}
|
||||
// SSRF 校验:拒绝内网/保留地址与重绑定解析(安全审计 E2)
|
||||
try {
|
||||
\crmeb\utils\SsrfGuard::assertSafeUrl($url);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if ($filename === '') {
|
||||
$ext = pathinfo($url, PATHINFO_EXTENSION);
|
||||
$ext = strtolower(pathinfo($url, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg', 'jpeg', 'png'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -22,6 +22,35 @@ class NoticeDao extends BaseDao
|
||||
return $this->getModel()->where('is_del', 0)->findOrEmpty($id)->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户侧单条详情:校验可见性(已发布/在发送窗口/未过期/定向含本人),防任意 id 读取未发布或他人定向消息(安全审计 B6.1)
|
||||
* @param int $id
|
||||
* @param int $uid
|
||||
* @return array
|
||||
*/
|
||||
public function getDetailForUser(int $id, int $uid): array
|
||||
{
|
||||
$now = time();
|
||||
$uidJson = json_encode($uid);
|
||||
$notice = $this->getModel()
|
||||
->where('is_del', 0)
|
||||
->where('id', $id)
|
||||
->where('status', Notice::STATUS_PUBLISHED)
|
||||
->where('send_at', '<=', $now)
|
||||
->where(function ($q) use ($now) {
|
||||
$q->where('expire_at', 0)->whereOr('expire_at', '>', $now);
|
||||
})
|
||||
->where(function ($q) use ($uid, $uidJson) {
|
||||
$q->where('target_type', Notice::TARGET_ALL)
|
||||
->whereOr(function ($q2) use ($uid, $uidJson) {
|
||||
$q2->where('target_type', Notice::TARGET_USER)
|
||||
->whereRaw("JSON_CONTAINS(target_ids, ?, '$')", [$uidJson]);
|
||||
});
|
||||
})
|
||||
->findOrEmpty();
|
||||
return $notice->isEmpty() ? [] : $notice->toArray();
|
||||
}
|
||||
|
||||
public function create(array $data): array
|
||||
{
|
||||
$data['created_at'] = time();
|
||||
|
||||
@ -94,6 +94,22 @@ class OrderCenterDao extends BaseDao
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按礼物领取令牌查找订单中台 ID(extra_info.gift.gift_key)
|
||||
* @param string $giftKey 32 位 hex 令牌
|
||||
* @return int|null
|
||||
*/
|
||||
public function getIdByGiftKey(string $giftKey): ?int
|
||||
{
|
||||
if ($giftKey === '' || !preg_match('/^[a-f0-9]{32}$/', $giftKey)) {
|
||||
return null;
|
||||
}
|
||||
$id = $this->getModel()
|
||||
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(extra_info, '$.gift.gift_key')) = ?", [$giftKey])
|
||||
->value('id');
|
||||
return $id ? (int)$id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存或更新记录
|
||||
* 根据条件判断是新增还是更新,支持幂等性操作
|
||||
|
||||
@ -31,17 +31,4 @@ class PayRecordDao extends BaseDao
|
||||
return $this->getModel()->where('order_no', $orderNo)->lock($lock)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取待恢复支付记录。
|
||||
* @param int $limit 批量上限
|
||||
* @return array
|
||||
*/
|
||||
public function recoverable(int $limit = 100): array
|
||||
{
|
||||
return $this->getModel()->where('is_del', 0)->where(function ($query) {
|
||||
$query->whereIn('state', ['prepared', 'creating', 'closing', 'received', 'compensating'])
|
||||
->whereOr('effects_state', 'pending')->whereOr('refund_state', 'PENDING')->whereOr('refund_state', 'SUBMITTING')
|
||||
->whereOr('refund_state', 'PROCESSING')->whereOr('refund_state', 'UNKNOWN');
|
||||
})->field('order_no')->order('last_check_time asc,id asc')->limit($limit)->column('order_no');
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,35 +54,47 @@ class CapitalFlowDao extends BaseDao
|
||||
*/
|
||||
public function getRecordList($where, int $page = 0, int $limit = 0)
|
||||
{
|
||||
$timeUnix = "%Y-%m-%d";
|
||||
switch ($where['type']) {
|
||||
case "day" :
|
||||
$timeUnix = "%Y-%m-%d";
|
||||
break;
|
||||
case "week" :
|
||||
$timeUnix = "%u";
|
||||
break;
|
||||
case "month" :
|
||||
$timeUnix = "%Y-%m";
|
||||
break;
|
||||
}
|
||||
$model = $this->search($where, false)
|
||||
->when(isset($where['type']) && $where['type'] !== '', function ($query) use ($where, $timeUnix) {
|
||||
$query->field("FROM_UNIXTIME(add_time,'$timeUnix') as day,sum(if(price >= 0,price,0)) as income_price,sum(if(price < 0,price,0)) as exp_price,add_time");
|
||||
$query->group("FROM_UNIXTIME(add_time, '$timeUnix')");
|
||||
});
|
||||
$count = $model->count();
|
||||
$list = $model->when($page && $limit, function ($query) use ($page, $limit) {
|
||||
$query->page($page, $limit);
|
||||
})->order('add_time desc')->select()->toArray();
|
||||
// type 只表示分组粒度,不是表字段;从 where 剥离,避免 search=true 时误写成 where type=day
|
||||
$type = (string)($where['type'] ?? 'day');
|
||||
unset($where['type']);
|
||||
|
||||
$timeUnix = match ($type) {
|
||||
'week' => '%u',
|
||||
'month' => '%Y-%m',
|
||||
default => '%Y-%m-%d',
|
||||
};
|
||||
// max(add_time) 满足 ONLY_FULL_GROUP_BY;裸 add_time 在严格 sql_mode 下会直接报错
|
||||
$field = "FROM_UNIXTIME(add_time,'{$timeUnix}') as day,"
|
||||
. "sum(if(price >= 0,price,0)) as income_price,"
|
||||
. "sum(if(price < 0,price,0)) as exp_price,"
|
||||
. "max(add_time) as add_time";
|
||||
$group = "FROM_UNIXTIME(add_time, '{$timeUnix}')";
|
||||
|
||||
// countWithGroup 会把当前查询的 field 改成 count(*),不能与 select 共用同一条 query
|
||||
$count = (int)$this->search($where, false)->field($field)->group($group)->count();
|
||||
$list = $this->search($where, false)
|
||||
->field($field)
|
||||
->group($group)
|
||||
->when($page && $limit, function ($query) use ($page, $limit) {
|
||||
$query->page($page, $limit);
|
||||
})
|
||||
->order('add_time desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$cashOnly = !empty($where['cash_only']);
|
||||
foreach ($list as &$item) {
|
||||
if ($where['type'] == 'day') {
|
||||
$item['ids'] = array_merge($this->getModel()->whereDay('add_time', $item['day'])->column('id'));
|
||||
} elseif ($where['type'] == 'week') {
|
||||
$day = $this->weekDayTime(date('Y'), $item['day']);
|
||||
$item['ids'] = array_merge($this->getModel()->whereWeek('add_time', $day)->column('id'));
|
||||
} elseif ($where['type'] == 'month') {
|
||||
$item['ids'] = array_merge($this->getModel()->whereMonth('add_time', $item['day'])->column('id'));
|
||||
$idsQuery = $this->getModel();
|
||||
if ($cashOnly) {
|
||||
$idsQuery = $idsQuery->whereNotIn('pay_type', ['', 'yue', 'admin']);
|
||||
}
|
||||
if ($type === 'day') {
|
||||
$item['ids'] = array_values($idsQuery->whereDay('add_time', $item['day'])->column('id'));
|
||||
} elseif ($type === 'week') {
|
||||
$day = $this->weekDayTime((int)date('Y'), (int)$item['day']);
|
||||
$item['ids'] = array_values($idsQuery->whereWeek('add_time', $day)->column('id'));
|
||||
} elseif ($type === 'month') {
|
||||
$item['ids'] = array_values($idsQuery->whereMonth('add_time', $item['day'])->column('id'));
|
||||
}
|
||||
}
|
||||
return compact('list', 'count');
|
||||
|
||||
@ -42,6 +42,9 @@ return [
|
||||
// ============ 管理员事件 ============
|
||||
'AdminLogin' => [\app\listener\admin\AdminLoginListener::class],
|
||||
|
||||
// ============ 一号通短信 ============
|
||||
'SmsBalanceCheck' => [\app\listener\sms\SmsBalanceCheckListener::class], //一号通短信余额检查事件
|
||||
|
||||
// ============ 注册中心事件(应用启动阶段触发,各监听器向对应 Registry 注册) ============
|
||||
'OrderTypeRegister' => [\app\listener\system\OrderTypeRegisterListener::class],
|
||||
// 统一长连接网关服务注册:返回实现 WsServiceHandlerInterface 的处理器类名,插件可在各自 event.php 追加监听
|
||||
|
||||
@ -82,10 +82,10 @@ class MiniOrderJob extends BaseJobs
|
||||
if (!$deliveryType) continue;
|
||||
$order['user_phone'] = $extra['contact']['phone'] ?? '';
|
||||
}
|
||||
\crmeb\services\pay\PaymentDispatch::replay(fn () => event('OrderShipping', [
|
||||
event('OrderShipping', [
|
||||
$kind === 'user_recharge' ? 'recharge' : $kind, $order, $deliveryType,
|
||||
$delivery['delivery_id'] ?? '', $delivery['delivery_name'] ?? '',
|
||||
]));
|
||||
]);
|
||||
}
|
||||
if (empty($res['has_more']) || empty($res['last_index'])) break;
|
||||
$params['last_index'] = $res['last_index'];
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
namespace app\jobs;
|
||||
|
||||
use app\dao\order\OrderCenterDao;
|
||||
use app\services\order\OrderLifecycleServices;
|
||||
use app\services\product\CenterProductServices;
|
||||
use app\services\user\UserLabelRelationServices;
|
||||
|
||||
@ -14,11 +14,7 @@ namespace app\jobs;
|
||||
|
||||
use app\dao\order\OrderCenterDao;
|
||||
use app\services\order\OrderCenterServices;
|
||||
use app\services\order\OrderLifecycleServices;
|
||||
use app\services\pay\PayRecordServices;
|
||||
use crmeb\basic\BaseJobs;
|
||||
use crmeb\trade\order\type\OrderTypeAdapterInterface;
|
||||
use crmeb\trade\order\type\OrderTypeRegistry;
|
||||
use think\facade\Log;
|
||||
use think\facade\Env;
|
||||
|
||||
@ -80,57 +76,20 @@ class UnpaidOrderCancelJob extends BaseJobs
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理中台架构订单的自动取消(所有产品类型统一)
|
||||
* 统一两步:
|
||||
* 1. OrderTypeAdapter::advance 推进扩展表状态 + onEnter 回调 + 中台状态同步
|
||||
* 2. OrderLifecycleServices::handleCancel 返还积分/优惠券、回滚库存、写日志
|
||||
* 到期后交给中台的同一次未支付取消。
|
||||
* @param array $orderInfo 订单中台记录
|
||||
* @return bool
|
||||
*/
|
||||
protected function handleCenterOrder(array $orderInfo): bool
|
||||
{
|
||||
$orderNo = $orderInfo['order_no'] ?? '';
|
||||
$statusCode = $orderInfo['status_code'] ?? '';
|
||||
$payStatus = (int)($orderInfo['pay_status'] ?? 0);
|
||||
$sourceType = $orderInfo['source_type'] ?? '';
|
||||
|
||||
// 已支付、已取消、已退款的无待处理
|
||||
if ($payStatus !== 0 || $statusCode === 'canceled' || $statusCode === 'refunded') {
|
||||
return true;
|
||||
}
|
||||
if (empty($orderNo) || empty($sourceType)) {
|
||||
$orderNo = (string)($orderInfo['order_no'] ?? '');
|
||||
if ($orderNo === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
app()->make(PayRecordServices::class)->closeBeforeCancel($orderNo);
|
||||
return app()->make(OrderCenterServices::class)->transaction(function () use ($orderNo, $sourceType, $orderInfo) {
|
||||
$locked = app()->make(OrderCenterDao::class)->getByIdForUpdate((int)$orderInfo['id']);
|
||||
if (!$locked || (int)$locked['pay_status'] !== 0 || in_array($locked['status_code'], ['canceled', 'deleted', 'refunded'], true)) return true;
|
||||
$orderInfo = $locked->toArray();
|
||||
// 通过订单类型注册器获取适配器,统一推进至已取消状态
|
||||
/** @var OrderTypeRegistry $registry */
|
||||
$registry = app()->make(OrderTypeRegistry::class);
|
||||
/** @var OrderTypeAdapterInterface $adapter */
|
||||
$adapter = $registry->getAdapter($sourceType);
|
||||
|
||||
// 1. advance 一步完成:扩展表状态推进 + onEnter 回调 + 中台 status_code 同步
|
||||
if ($adapter->advance($orderNo, OrderTypeAdapterInterface::EXT_STATUS_CANCELED) === false) {
|
||||
throw new \RuntimeException('扩展订单取消失败');
|
||||
}
|
||||
|
||||
// 2. 补写中台 cancel_time(advance 内部只同步 status_code)
|
||||
app()->make(OrderCenterServices::class)->syncStatus(
|
||||
$orderNo,
|
||||
OrderTypeAdapterInterface::STATUS_CANCELED,
|
||||
['cancel_time' => time()]
|
||||
);
|
||||
|
||||
// 3. 通用生命周期处理:返还积分/优惠券、回滚库存、写日志
|
||||
app()->make(OrderLifecycleServices::class)->handleCancel($orderInfo);
|
||||
|
||||
app()->make(OrderCenterServices::class)->cancelUnpaidOrder($orderNo, 0, 'system');
|
||||
return true;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[UnpaidOrderCancelJob] 中台订单自动取消失败, order_no=' . $orderNo . ', 原因:' . $e->getMessage());
|
||||
return false;
|
||||
|
||||
@ -16,7 +16,6 @@ use app\services\driver\printer\SystemTicketServices;
|
||||
use crmeb\basic\BaseJobs;
|
||||
use crmeb\utils\Str;
|
||||
use think\facade\Log;
|
||||
use think\facade\Env;
|
||||
|
||||
/**
|
||||
* 小票打印
|
||||
@ -97,23 +96,4 @@ class PrintJob extends BaseJobs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印使用独立队列重试,不阻塞支付后置通知和商品日志
|
||||
* @param string|array $action 任务方法或参数
|
||||
* @param array $data 任务参数
|
||||
* @return mixed
|
||||
*/
|
||||
public static function runPaymentTask($action, array $data = [])
|
||||
{
|
||||
$method = is_array($action) ? 'doJob' : $action;
|
||||
if (!is_string($method) || !method_exists(static::class, $method)) throw new \RuntimeException('打印任务方法不存在');
|
||||
if (sys_config('queue_open', 0) == 1 && Env::get('cache.driver', 'file') === 'redis') {
|
||||
$result = static::buildQueue($action, $data);
|
||||
if (!$result) throw new \RuntimeException('打印任务入队失败');
|
||||
return $result;
|
||||
}
|
||||
// 未开启队列时只尝试一次,失败留痕但不能卡住支付任务链。
|
||||
static::executeSync($action, $data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,11 +37,7 @@ class NotifyListener
|
||||
if (in_array($payType, ['weixin', 'weixin_v3'], true) && ($notify['event_type'] ?? '') === 'REFUND.SUCCESS') {
|
||||
$record = app()->make(PayRecordServices::class)->find((string)($notify['out_trade_no'] ?? ''));
|
||||
if ($record && ($record['refund']['refund_no'] ?? '') === ($notify['out_refund_no'] ?? '')) {
|
||||
if ($payType === 'weixin') {
|
||||
return app()->make(PayRecordServices::class)->handleWechatRefundNotify($notify, $payType);
|
||||
}
|
||||
app()->make(PayRecordServices::class)->processRefund($record['order_no']);
|
||||
return true;
|
||||
return app()->make(PayRecordServices::class)->applyRefundNotify($record['order_no'], $notify, $payType);
|
||||
}
|
||||
return app()->make(RefundOrderServices::class)->handleWechatRefundNotify($notify, $payType);
|
||||
}
|
||||
|
||||
64
backend/app/listener/sms/SmsBalanceCheckListener.php
Normal file
64
backend/app/listener/sms/SmsBalanceCheckListener.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: CRMEB Team <admin@crmeb.com>
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\listener\sms;
|
||||
|
||||
use crmeb\interfaces\ListenerInterface;
|
||||
use crmeb\services\CacheService;
|
||||
use crmeb\services\yihaotong\Serve;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 一号通短信余额检查
|
||||
* Class SmsBalanceCheckListener
|
||||
* @package app\listener\sms
|
||||
*/
|
||||
class SmsBalanceCheckListener implements ListenerInterface
|
||||
{
|
||||
/**
|
||||
* 短信余额预警阈值(条)
|
||||
*/
|
||||
public const BALANCE_THRESHOLD = 100;
|
||||
|
||||
/**
|
||||
* 实际请求一号通的节流窗口(秒)
|
||||
*/
|
||||
public const CHECK_INTERVAL = 600;
|
||||
|
||||
/**
|
||||
* 一号通短信余额检查(节流后请求 user/info,低于阈值记日志)
|
||||
* @param mixed $event
|
||||
* @return void
|
||||
*/
|
||||
public function handle($event): void
|
||||
{
|
||||
try {
|
||||
//节流:窗口期内不重复请求一号通
|
||||
if (CacheService::get('sms_balance_check_time')) {
|
||||
return;
|
||||
}
|
||||
CacheService::set('sms_balance_check_time', time(), self::CHECK_INTERVAL);
|
||||
|
||||
/** @var Serve $serve */
|
||||
$serve = app()->make(Serve::class, [yihaotong_config()]);
|
||||
$info = $serve->getUser();
|
||||
//未开通一号通短信不处理
|
||||
if (empty($info['sms']['open'])) {
|
||||
return;
|
||||
}
|
||||
$num = (int)($info['sms']['num'] ?? 0);
|
||||
if ($num <= self::BALANCE_THRESHOLD) {
|
||||
Log::error('一号通短信余额不足,剩余' . $num . '条,请及时充值');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('一号通短信余额检查失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,5 +68,8 @@ class UserLoginListener implements ListenerInterface
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('用户登录监听失败,失败原因:' . $e->getMessage());
|
||||
}
|
||||
|
||||
//一号通短信余额检查(监听器内有节流)
|
||||
event('SmsBalanceCheck');
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,4 @@ return [
|
||||
// \think\middleware\TraceDebug::class,
|
||||
//初始化基础中间件
|
||||
\app\http\middleware\BaseMiddleware::class,
|
||||
// 多语言支持
|
||||
\think\middleware\LoadLangPack::class,
|
||||
];
|
||||
|
||||
@ -37,6 +37,12 @@ class UploadStorage extends BaseModel
|
||||
*/
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 关闭框架对 update_time 字段的日期字符串转换,保持 Unix 时间戳
|
||||
* @var bool
|
||||
*/
|
||||
protected $updateTime = false;
|
||||
|
||||
/**
|
||||
* 按驱动隔离存储空间查询
|
||||
* @param mixed $query
|
||||
|
||||
@ -138,49 +138,85 @@ class StoreCouponUserServices extends BaseService
|
||||
{
|
||||
$userCoupons = $this->dao->getUserAllCoupon($uid);
|
||||
$result = [];
|
||||
if ($userCoupons) {
|
||||
$cartInfo = $cartGroup['valid'];
|
||||
foreach ($userCoupons as $coupon) {
|
||||
$price = 0;
|
||||
$count = 0;
|
||||
switch ($coupon['applicable_type']) {
|
||||
case 0:
|
||||
case 3:
|
||||
foreach ($cartInfo as $cart) {
|
||||
$price += bcmul((string)$cart['truePrice'], (string)$cart['cart_num'], 2);
|
||||
$count++;
|
||||
}
|
||||
break;
|
||||
case 1://品类券
|
||||
/** @var StoreCategoryServices $storeCategoryServices */
|
||||
$storeCategoryServices = app()->make(StoreCategoryServices::class);
|
||||
$coupon_category = explode(',', (string)$coupon['category_id']);
|
||||
$category_ids = $storeCategoryServices->getAllById($coupon_category);
|
||||
if ($category_ids) {
|
||||
$cateIds = array_column($category_ids, 'id');
|
||||
foreach ($cartInfo as $cart) {
|
||||
if (isset($cart['productInfo']['cate_id']) && array_intersect(explode(',', $cart['productInfo']['cate_id']), $cateIds)) {
|
||||
$price += bcmul((string)$cart['truePrice'], (string)$cart['cart_num'], 2);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
foreach ($cartInfo as $cart) {
|
||||
if (isset($cart['product_id']) && in_array($cart['product_id'], explode(',', $coupon['product_id']))) {
|
||||
$price += bcmul((string)$cart['truePrice'], (string)$cart['cart_num'], 2);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
$cartInfo = $cartGroup['valid'] ?? [];
|
||||
if (!$userCoupons || empty($cartInfo)) {
|
||||
return $result;
|
||||
}
|
||||
// 与 OrderComputedServices::couponMatchedSubset 同口径,避免确认页/试算只回填最优一张、其余可用券被错误过滤
|
||||
$items = [];
|
||||
foreach ($cartInfo as $cart) {
|
||||
if (is_object($cart)) {
|
||||
$cart = $cart->toArray();
|
||||
}
|
||||
$cateId = $cart['productInfo']['cate_id'] ?? '';
|
||||
if (is_array($cateId)) {
|
||||
$cateId = implode(',', $cateId);
|
||||
}
|
||||
$items[] = [
|
||||
'product_id' => (int)($cart['product_id'] ?? 0),
|
||||
'cate_ids' => array_map('intval', array_filter(explode(',', (string)$cateId))),
|
||||
'price' => bcmul((string)($cart['truePrice'] ?? '0'), (string)($cart['cart_num'] ?? '0'), 2),
|
||||
];
|
||||
}
|
||||
foreach ($userCoupons as $coupon) {
|
||||
[$subset, $count] = $this->couponMatchedSubsetPrice($coupon, $items);
|
||||
if (!$count || bccomp((string)($coupon['use_min_price'] ?? '0'), $subset, 2) > 0) {
|
||||
continue;
|
||||
}
|
||||
$result[] = $this->formatUsableCouponRow($coupon);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算优惠券对购物车适用子集金额与命中条数(与订单自动选券口径一致)
|
||||
* @param array $coupon
|
||||
* @param array $items [['product_id'=>int,'cate_ids'=>int[],'price'=>string], ...]
|
||||
* @return array [subsetPrice, matchedCount]
|
||||
*/
|
||||
protected function couponMatchedSubsetPrice(array $coupon, array $items): array
|
||||
{
|
||||
$subset = '0';
|
||||
$count = 0;
|
||||
$type = (int)($coupon['applicable_type'] ?? 0);
|
||||
if (in_array($type, [0, 3], true)) {
|
||||
foreach ($items as $item) {
|
||||
$subset = bcadd($subset, $item['price'], 2);
|
||||
$count++;
|
||||
}
|
||||
return [$subset, $count];
|
||||
}
|
||||
if ($type === 1) {
|
||||
$couponCategory = array_values(array_filter(explode(',', (string)($coupon['category_id'] ?? ''))));
|
||||
$cateIds = [];
|
||||
if ($couponCategory) {
|
||||
$categoryIds = app()->make(\app\dao\product\product\StoreCategoryDao::class)->getAllById($couponCategory);
|
||||
$cateIds = $categoryIds ? array_map('intval', array_column($categoryIds, 'id')) : [];
|
||||
}
|
||||
if (!$cateIds) {
|
||||
return [$subset, 0];
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if (!empty($item['cate_ids']) && array_intersect($item['cate_ids'], $cateIds)) {
|
||||
$subset = bcadd($subset, $item['price'], 2);
|
||||
$count++;
|
||||
}
|
||||
if ($count && $coupon['use_min_price'] <= $price) {
|
||||
$result[] = $this->formatUsableCouponRow($coupon);
|
||||
}
|
||||
return [$subset, $count];
|
||||
}
|
||||
if ($type === 2) {
|
||||
$productIds = array_map('intval', array_filter(explode(',', (string)($coupon['product_id'] ?? ''))));
|
||||
if (!$productIds) {
|
||||
return [$subset, 0];
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if (in_array((int)$item['product_id'], $productIds, true)) {
|
||||
$subset = bcadd($subset, $item['price'], 2);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
return [$subset, $count];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types=1);
|
||||
|
||||
namespace app\services;
|
||||
namespace app\services\channel;
|
||||
|
||||
use crmeb\plugin\PluginManager;
|
||||
|
||||
@ -20,7 +20,7 @@ use crmeb\plugin\PluginManager;
|
||||
* 每个端渠道通过 App::detect() 自描述检测逻辑
|
||||
* 新增平台只需在 modules/channel/ 下创建新目录并声明 plugin.json(type=channel),无需改代码
|
||||
* Class PlatformDetector
|
||||
* @package app\services
|
||||
* @package app\services\channel
|
||||
*/
|
||||
class PlatformDetector
|
||||
{
|
||||
@ -295,6 +295,13 @@ class SmsDriverServices extends BaseService
|
||||
if (!$driver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 脏数据自愈:历史安装曾把多个内置短信驱动同时置为启用,运行时收敛为互斥
|
||||
$enabledCount = (int)$this->dao->count(['status' => 1]);
|
||||
if ($enabledCount > 1) {
|
||||
$this->dao->disableAllExcept((int)$driver->id);
|
||||
}
|
||||
|
||||
$data = $driver->toArray();
|
||||
|
||||
// 一号通驱动:yihaotong_appid/yihaotong_appsecret 存储在系统配置表 sys_config 中
|
||||
|
||||
@ -432,12 +432,24 @@ class UploadDriverServices extends BaseService
|
||||
}
|
||||
$data['thumb_watermark_config'] = $this->prepareCommonConfig($driver->thumb_watermark_config ?: [], $data['thumb_watermark_config']);
|
||||
}
|
||||
// 基础配置(密钥/名称等)允许先保存;仅「启用驱动」要求已有使用中的存储空间。
|
||||
$enableBlocked = false;
|
||||
if (($data['status'] ?? 0) === 1 && $driver->driver_key !== 'local'
|
||||
&& !app()->make(UploadStorageDao::class)->count(['driver_key' => $driver->driver_key, 'status' => 1, 'is_delete' => 0])) {
|
||||
// 不含 status=1 时顺带写入的 config_fields,避免纯启用被误判为“同时保存配置”
|
||||
$hasOtherUpdates = $configValues !== null
|
||||
|| array_key_exists('driver_name', $data)
|
||||
|| array_key_exists('sort', $data)
|
||||
|| array_key_exists('thumb_watermark_config', $data);
|
||||
if (!$hasOtherUpdates) {
|
||||
throw new AdminException('未有正在使用的存储空间,请先在「空间配置」中添加并开启存储空间');
|
||||
}
|
||||
$data['status'] = (int)$driver->status;
|
||||
$enableBlocked = true;
|
||||
}
|
||||
|
||||
$this->transaction(function () use ($id, $driver, $data) {
|
||||
// 状态切换与配置保存必须一起成功,失败时回滚其他驱动的停用。
|
||||
if (($data['status'] ?? 0) === 1 && $driver->driver_key !== 'local'
|
||||
&& !app()->make(UploadStorageDao::class)->count(['driver_key' => $driver->driver_key, 'status' => 1, 'is_delete' => 0])) {
|
||||
throw new AdminException('未有正在使用的存储空间');
|
||||
}
|
||||
if (($data['status'] ?? 0) === 1 && $this->dao->disableAllExcept($id) === false) {
|
||||
throw new \RuntimeException('切换存储驱动失败');
|
||||
}
|
||||
@ -451,7 +463,7 @@ class UploadDriverServices extends BaseService
|
||||
});
|
||||
$this->invalidateUploadCache();
|
||||
|
||||
return true;
|
||||
return $enableBlocked ? 'config_saved_enable_blocked' : true;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -612,6 +624,44 @@ class UploadDriverServices extends BaseService
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 plugin.json 中带前缀的配置键映射为驱动 initialize() 期望的短键
|
||||
*
|
||||
* 优先剥离 {driverKey}_;兼容历史厂商前缀(如 cos 的 tengxun_、obs 的 hw_)。
|
||||
*
|
||||
* @param string $driverKey
|
||||
* @param array $config
|
||||
* @return array
|
||||
*/
|
||||
public function normalizeDriverConfig(string $driverKey, array $config): array
|
||||
{
|
||||
$prefix = $driverKey . '_';
|
||||
foreach ($config as $name => $value) {
|
||||
if (!is_string($name) || !str_starts_with($name, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
$shortName = substr($name, strlen($prefix));
|
||||
if ($shortName !== '' && !array_key_exists($shortName, $config)) {
|
||||
$config[$shortName] = $value;
|
||||
}
|
||||
}
|
||||
foreach (['accessKey', 'secretKey', 'appid', 'storageRegion'] as $short) {
|
||||
if (($config[$short] ?? '') !== '' && $config[$short] !== null) {
|
||||
continue;
|
||||
}
|
||||
foreach ($config as $name => $value) {
|
||||
if (!is_string($name) || $name === $short || $value === '' || $value === null) {
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($name, '_' . $short)) {
|
||||
$config[$short] = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅失效上传空间缓存与进程内驱动字典
|
||||
* @return void
|
||||
|
||||
@ -214,17 +214,8 @@ class UploadService
|
||||
$config[$fieldName] = $field['value'] ?? '';
|
||||
}
|
||||
|
||||
// 映射带前缀的字段名:plugin.json 中可能定义了 {driverKey}_accessKey 等,
|
||||
// 但驱动 initialize() 期望 accessKey(无前缀)
|
||||
$prefix = $driverKey . '_';
|
||||
foreach ($config as $key => $value) {
|
||||
if (strpos($key, $prefix) === 0) {
|
||||
$unprefixed = substr($key, strlen($prefix));
|
||||
if (!isset($config[$unprefixed])) {
|
||||
$config[$unprefixed] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 映射带前缀的字段名:plugin.json 中可能是 {driverKey}_accessKey 或历史厂商前缀
|
||||
$config = $driverServices->normalizeDriverConfig($driverKey, $config);
|
||||
|
||||
/** @var UploadStorageServices $storageServices */
|
||||
$storageServices = app()->make(UploadStorageServices::class);
|
||||
|
||||
@ -143,12 +143,7 @@ class UploadStorageServices extends BaseService
|
||||
{
|
||||
$config = [];
|
||||
foreach ($this->prepareConfigFields($driverKey, []) as $field) $config[$field['field']] = $field['value'] ?? '';
|
||||
foreach ($config as $name => $value) {
|
||||
if (str_starts_with($name, $driverKey . '_')) {
|
||||
$shortName = substr($name, strlen($driverKey) + 1);
|
||||
if (!array_key_exists($shortName, $config)) $config[$shortName] = $value;
|
||||
}
|
||||
}
|
||||
$config = $this->driverServices->normalizeDriverConfig($driverKey, $config);
|
||||
$config['storageName'] = $storage['name'] ?? '';
|
||||
$config['storageRegion'] = $storage['region'] ?? ($config['storageRegion'] ?? '');
|
||||
$config['uploadUrl'] = $storage['domain'] ?? '';
|
||||
@ -308,7 +303,10 @@ class UploadStorageServices extends BaseService
|
||||
} else {
|
||||
$this->validateStorageUrl($info['domain'], 100, false);
|
||||
$upload = $this->storageUpload($driverKey, $info);
|
||||
if ($upload->setBucketCors($info['name'], $info['region']) === false) throw new \Exception(getLang('设置云空间跨域规则失败'));
|
||||
if ($upload->setBucketCors($info['name'], $info['region']) === false) {
|
||||
$error = method_exists($upload, 'getError') ? (string)$upload->getError() : '';
|
||||
throw new AdminException($error !== '' ? $error : '设置云空间跨域规则失败');
|
||||
}
|
||||
}
|
||||
$this->transaction(function () use ($info, $driverKey, $status) {
|
||||
if ($status === 1 && $this->dao->disableByDriverKey($driverKey) === false) throw new \Exception(getLang('切换云空间失败'));
|
||||
@ -365,8 +363,8 @@ class UploadStorageServices extends BaseService
|
||||
$item['cname'] = parse_url($item['domain'], PHP_URL_HOST) ?: '';
|
||||
}
|
||||
$item['_region'] = $this->getRegionName($item['region'], $driverKey ?: $item['driver_key']);
|
||||
$item['_add_time'] = date('Y-m-d H:i:s', (int)$item['add_time']);
|
||||
$item['_update_time'] = date('Y-m-d H:i:s', (int)$item['update_time']);
|
||||
$item['_add_time'] = $this->formatStorageTime($item['add_time'] ?? 0);
|
||||
$item['_update_time'] = $this->formatStorageTime($item['update_time'] ?? 0);
|
||||
}
|
||||
$count = $this->dao->count($where);
|
||||
return compact('list', 'count');
|
||||
@ -388,6 +386,23 @@ class UploadStorageServices extends BaseService
|
||||
return $map[$region] ?? $region;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容时间戳与日期字符串,避免 (int)'2026-11-02 ...' 被截成 2026
|
||||
* @param mixed $value
|
||||
* @return string
|
||||
*/
|
||||
protected function formatStorageTime($value): string
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
$timestamp = (int)$value;
|
||||
} elseif (is_string($value) && $value !== '') {
|
||||
$timestamp = strtotime($value) ?: 0;
|
||||
} else {
|
||||
$timestamp = 0;
|
||||
}
|
||||
return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取添加存储空间表单
|
||||
* @param string $driverKey
|
||||
@ -493,33 +508,44 @@ class UploadStorageServices extends BaseService
|
||||
$storage = $this->dao->get(['driver_key' => $driverKey, 'status' => 1, 'is_delete' => 0]);
|
||||
$driver = $this->storageUpload($driverKey, $storage ?: []);
|
||||
$buckets = $driver->listbuckets($storage['region'] ?? '');
|
||||
if ($buckets === false) throw new \Exception(getLang('同步云空间失败'));
|
||||
if (!is_array($buckets)) throw new \Exception(getLang('云空间列表格式错误'));
|
||||
$existingNames = $this->dao->getColumn(['driver_key' => $driverKey, 'is_delete' => 0], 'name');
|
||||
if ($buckets === false) {
|
||||
$error = method_exists($driver, 'getError') ? (string)$driver->getError() : '';
|
||||
throw new AdminException($error !== '' ? $error : '同步云空间失败');
|
||||
}
|
||||
if (!is_array($buckets)) throw new AdminException('云空间列表格式错误');
|
||||
$localList = $this->dao->getList(['driver_key' => $driverKey], ['id', 'name', 'status']);
|
||||
$existingNames = array_column($localList, 'name');
|
||||
$type = $this->driverServices->getTypeByDriverKey($driverKey);
|
||||
$driverInfo = $this->driverServices->getByDriverKey($driverKey);
|
||||
$domainTemplate = $driverInfo['domain_template'] ?? '';
|
||||
$appid = '';
|
||||
if (str_contains($domainTemplate, '{appid}')) {
|
||||
$config = $this->getStorageConfigByDriverKey($driverKey);
|
||||
$appid = (string)($config['appid'] ?? $config[$driverKey . '_appid'] ?? '');
|
||||
$config = $this->driverServices->normalizeDriverConfig($driverKey, $this->getStorageConfigByDriverKey($driverKey));
|
||||
$appid = (string)($config['appid'] ?? '');
|
||||
}
|
||||
// 部分云厂商仅返回 1 个桶时是关联数组,不是桶列表
|
||||
if (is_array($buckets) && $buckets !== [] && !array_is_list($buckets)
|
||||
&& (isset($buckets['Name']) || isset($buckets['name']) || isset($buckets['id']))) {
|
||||
$buckets = [$buckets];
|
||||
}
|
||||
|
||||
$records = [];
|
||||
$remoteNames = [];
|
||||
foreach ($buckets as $bucket) {
|
||||
if (is_array($bucket) || is_object($bucket)) {
|
||||
$bucketArr = (array)$bucket;
|
||||
$name = $bucketArr['name'] ?? $bucketArr['Name'] ?? $bucketArr['id'] ?? '';
|
||||
$region = $bucketArr['region'] ?? $bucketArr['Region'] ?? $bucketArr['location'] ?? $bucketArr['Location'] ?? '';
|
||||
} elseif (is_string($bucket)) {
|
||||
$name = $bucket;
|
||||
$region = '';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_string($name) || !is_string($region) || $name === '' || in_array($name, $existingNames, true)) continue;
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{0,99}$/i', $name)) throw new AdminException('云空间名称格式错误');
|
||||
if (strlen($region) > 100 || ($region !== '' && !preg_match('/^[a-z0-9-]+$/i', $region))) throw new AdminException('云空间区域格式错误');
|
||||
if (!is_string($name) || !is_string($region) || $name === '') continue;
|
||||
// 同步远端数据时跳过非法项,避免单个脏字段中断整次同步
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{0,99}$/i', $name)) continue;
|
||||
if (strlen($region) > 100 || ($region !== '' && !preg_match('/^[a-z0-9-]+$/i', $region))) continue;
|
||||
$remoteNames[] = $name;
|
||||
if (in_array($name, $existingNames, true)) continue;
|
||||
$domain = $this->formatStorageDomain($domainTemplate, $name, $region, $appid);
|
||||
$records[] = [
|
||||
'name' => $name,
|
||||
@ -535,10 +561,36 @@ class UploadStorageServices extends BaseService
|
||||
];
|
||||
$existingNames[] = $name;
|
||||
}
|
||||
if ($records) {
|
||||
// 远程请求已完成,事务中只执行批量元数据写入。
|
||||
$this->transaction(function () use ($records) {
|
||||
if ($this->dao->saveAll($records) === false) throw new \Exception(getLang('保存同步云空间失败'));
|
||||
|
||||
$removeIds = [];
|
||||
$removedActive = false;
|
||||
foreach ($localList as $local) {
|
||||
if (in_array($local['name'], $remoteNames, true)) continue;
|
||||
$removeIds[] = (int)$local['id'];
|
||||
if ((int)$local['status'] === 1) $removedActive = true;
|
||||
}
|
||||
|
||||
if ($records || $removeIds) {
|
||||
// 远程请求已完成,事务中只执行本地增删元数据。
|
||||
$this->transaction(function () use ($records, $removeIds, $removedActive, $driverKey) {
|
||||
if ($records && $this->dao->saveAll($records) === false) {
|
||||
throw new \Exception(getLang('保存同步云空间失败'));
|
||||
}
|
||||
if ($removeIds) {
|
||||
$deleted = $this->dao->getDb()->whereIn('id', $removeIds)->where('is_delete', 0)->update([
|
||||
'is_delete' => 1,
|
||||
'status' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if ($deleted === false) throw new AdminException('同步删除本地云空间失败');
|
||||
}
|
||||
// 正在使用的空间被远端删除后,回退到本地存储,避免上传驱动悬空
|
||||
if ($removedActive) {
|
||||
$enabled = $this->driverServices->getEnabledDrivers();
|
||||
if (!empty($enabled) && ($enabled[0]['driver_key'] ?? '') === $driverKey) {
|
||||
$this->driverServices->updateStatus('local', 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
CacheService::delete('storage_list');
|
||||
}
|
||||
@ -668,9 +720,14 @@ class UploadStorageServices extends BaseService
|
||||
{
|
||||
$driver = $this->driverServices->getByDriverKey($driverKey);
|
||||
$domainTemplate = $driver['domain_template'] ?? '';
|
||||
if ($appid === '' && str_contains($domainTemplate, '{appid}')) {
|
||||
$config = $this->getStorageConfigByDriverKey($driverKey);
|
||||
$appid = (string)($config['appid'] ?? $config[$driverKey . '_appid'] ?? '');
|
||||
if ($appid === '') {
|
||||
$config = $this->driverServices->normalizeDriverConfig($driverKey, $this->getStorageConfigByDriverKey($driverKey));
|
||||
$appid = (string)($config['appid'] ?? '');
|
||||
}
|
||||
// 腾讯云桶名在控制台/API 中通常已含 -APPID;手动填写短名时再补上
|
||||
if ($driverKey === 'cos' && $appid !== '' && !str_contains($domainTemplate, '{appid}')
|
||||
&& !str_ends_with($name, '-' . $appid)) {
|
||||
$name .= '-' . $appid;
|
||||
}
|
||||
return $this->formatStorageDomain($domainTemplate, $name, $region, $appid);
|
||||
}
|
||||
|
||||
@ -111,34 +111,30 @@ class MessageSystemServices extends BaseService
|
||||
return $noticeService->markAllAsRead((int)$id['uid']);
|
||||
}
|
||||
|
||||
$noticeId = (int)$id;
|
||||
// 标记已读
|
||||
if (isset($data['look'])) {
|
||||
$uid = $this->getUserIdForNotice($noticeId);
|
||||
return $uid > 0 ? $noticeService->markAsRead($noticeId, $uid) : true;
|
||||
}
|
||||
// 标记删除
|
||||
if (isset($data['is_del'])) {
|
||||
$uid = $this->getUserIdForNotice($noticeId);
|
||||
return $uid > 0 ? $noticeService->deleteForUser($noticeId, $uid) : true;
|
||||
}
|
||||
|
||||
// 其他字段更新:旧表 eb_message_system 已于 v8.1.0 随通知重构删除,数据已迁至 eb_notice,
|
||||
// 不再提供已读/删除以外的更新能力,禁止回落到废弃 DAO 触发 SQLSTATE[42S02]
|
||||
// 单条更新必须走 updateForUser,禁止按 notice_id 反查他人 uid(防 IDOR)
|
||||
throw new ApiException('站内信仅支持标记已读或删除操作');
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 notice_id 查询关联的 uid(从 notice_read 表)
|
||||
* @param int $noticeId
|
||||
* @return int
|
||||
* 当前用户对指定站内信标记已读/删除
|
||||
* @param int $uid 当前登录用户
|
||||
* @param int $noticeId 通知 ID
|
||||
* @param array $data 含 look 或 is_del
|
||||
* @return mixed
|
||||
*/
|
||||
private function getUserIdForNotice(int $noticeId): int
|
||||
public function updateForUser(int $uid, int $noticeId, array $data)
|
||||
{
|
||||
$readDao = app()->make(\app\dao\message\NoticeReadDao::class);
|
||||
$record = $readDao->getModel()
|
||||
->where('notice_id', $noticeId)
|
||||
->find();
|
||||
return $record ? (int)$record['uid'] : 0;
|
||||
if ($uid <= 0 || $noticeId <= 0) {
|
||||
throw new ApiException('参数错误');
|
||||
}
|
||||
/** @var NoticeServices $noticeService */
|
||||
$noticeService = app()->make(NoticeServices::class);
|
||||
if (isset($data['look'])) {
|
||||
return $noticeService->markAsRead($noticeId, $uid);
|
||||
}
|
||||
if (isset($data['is_del'])) {
|
||||
return $noticeService->deleteForUser($noticeId, $uid);
|
||||
}
|
||||
throw new ApiException('站内信仅支持标记已读或删除操作');
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user