This commit is contained in:
evoxwht 2026-11-12 16:49:58 +08:00
parent 42b234fe45
commit ae20f10745
1226 changed files with 21154 additions and 68160 deletions

View File

@ -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` 规则骨架、工具入口、目录契约与文档体系(模板类技能,仅按需用于规范化治理) | 「项目规范化」「工程规范审计」「新项目初始化」 |

View File

@ -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) | 项目、架构、模块与运行说明,不代替当前规则或执行授权 |

View File

@ -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 |

View File

@ -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. **编辑纪律**:同一文件多处修改必须逐处顺序编辑,禁止同批并行编辑同一文件(会互相覆盖静默丢失改动)。

View File

@ -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 与覆盖的版本区间。
## 八、违规判定(验收红线)

View File

@ -111,6 +111,7 @@ backend/
│
├── crmeb/ 核心底层(稳定不变)
│ ├── basic/ 基础类
│ ├── channel/ 用户渠道认证契约(ChannelAuthenticatorInterface/BaseChannelAuthenticator,提供授权类登录的渠道模块 Authenticator 必须继承实现,经 ChannelServiceProxy 事件被 AuthController 调度)
│ ├── trade/ 交易领域
│ │ ├── mode/ 交易模式(TradeModeHandlerInterface/TradeModeRegistry/BaseTradeModeHandler/DefaultTradeModeHandler)
│ │ ├── order/ 订单类型 + 售后规则 + 创建管线(type/create/aftersale)

View File

@ -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. 修复死链时同步核对目标是否为唯一真源,禁止把链接改向非真源副本。

View 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 处复用 | ✅ |

View File

@ -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/)

View File

@ -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 标签选择器在小程序端的警告为存量问题。

View File

@ -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

View File

@ -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` 不要恢复)。
## 四、列表页骨架(搜索卡 + 数据卡 + 分页区)

View File

@ -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) |
## 用户端业务流程

View File

@ -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`)。

View File

@ -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-插件机制) |

View File

@ -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 —— 站内信内容表

View File

@ -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 | 说明 |
|---------|-------|------|-----|------|

View File

@ -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` 支持 `:字段` 占位替换行数据) | — |

View File

@ -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 | 会员到期提醒 | 预留 |

View File

@ -68,7 +68,7 @@ AfterSaleServices::refund
幂等设计要点:
- `PaymentDispatch::replay` 保证 `OrderRefundSuccess` 事件重放安全;触发失败转待办 `recoverSuccessEvents` 补偿。
- `OrderRefundSuccess` 在资金事务外直接 `event()`。触发失败把效果记回待办,由 `recoverSuccessEvents` 再投递。
- Handler 中的资金/资源副作用一律经 `RefundEffectContextInterface::once()` 登记去重,禁止直接裸写。
- 中台 `refund_status`(0 无 / 1 申请中 / 2 已退)由 Adapter `resolveRefundStatus` 推导,退款完成时 `syncStatus` 同步。

View File

@ -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` 看到已支付直接返回,不重复推进

View File

@ -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` |

View File

@ -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 无需增加中央类型分支即可渲染新能力。新增产品类型或交易模式时,优先向现有前端契约添加数据,不修改核心前端。

View File

@ -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)。

View File

@ -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 会加载失败。

View File

@ -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 时确认后台仅启用一条,避免误读「仍走一号通」。

View File

@ -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**,三块对了就能通。

View File

@ -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` 分支。

View File

@ -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 字段与前端取值不一致 |
| 插件启用但无效果 | 未改核心时的预期行为,交付说明须写明 |

View File

@ -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;
}'
```

View File

@ -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` 完成。

View File

@ -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 自行签名(与现役模式差异较大,优先与用户确认架构)。

View File

@ -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`**。

View File

@ -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`**;四者任一错位,表现都是上传失败,但报错各不相同。

View File

@ -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` 实现保持一致。

View File

@ -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 事件外)。

View File

@ -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 ──► 改用户权益(积分/券/次数),不改成交价
```

View File

@ -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**。

View File

@ -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`

View File

@ -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 白名单删除链接;独有分类仅在分类下已无链接时删除,避免误删商家在插件分类下自建的链接。

View 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 的限制同样适用。

View File

@ -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) 禁止入库清单

View File

@ -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 描述属于过期资料。

View File

@ -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. 提供适合触屏和小屏幕的加载、空数据、重试、错误和禁止重复提交状态。

View File

@ -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
View File

@ -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
View 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
View File

@ -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>
![Marketplace](help/images/插件市场.png)
---
### 🧩 **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)
![QR Code](help/images/%E5%BC%80%E6%BA%90PHP1.jpg)
---
**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.

View File

@ -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>
![输入图片说明](help/images/插件市场.png)
---
@ -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(电脑端打开)
![二维码](help/images/%E5%BC%80%E6%BA%90PHP1.jpg)
---
**宿主机环境(推荐)**
```bash

View File

@ -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

View File

@ -1,5 +1,5 @@
version=CRMEB-KY v8.0.0
version_code=800
platform=GITEE
platform=GITHUB
app_id=ze7x9rxsv09l6pvsyo
app_key=fuF7U9zaybLa5gageVQzxtxQMFnvU2OI

View File

@ -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));
}

View File

@ -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);
}

View File

@ -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('保存成功');
}

View File

@ -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('已提交原路退款');
}
}

View File

@ -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 格式)');

View File

@ -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('证书文件路径不合法或文件不存在');
}
/**

View File

@ -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 {

View File

@ -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()

View File

@ -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('替换成功');
}
}

View File

@ -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
}

View File

@ -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,

View File

@ -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' => '登录相关']);

View File

@ -136,6 +136,7 @@ Route::group('setting', function () {
'index' => '获取组合数据列表',
'create' => '获取组合数据表单',
'save' => '保存组合数据',
'read' => '获取组合数据详情',
'edit' => '获取修改组合数据表单',
'update' => '修改组合数据',
'delete' => '删除组合数据'

View File

@ -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版本列表']);
//添加版本信息

View File

@ -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('注册失败');

View File

@ -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' => []]);
}
/**
* 自动收货
*/

View File

@ -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]);

View File

@ -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;

View File

@ -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

View File

@ -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);
}

View File

@ -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());
}
}
/**

View File

@ -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('设置成功');
}

View File

@ -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

View 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);
}
}

View File

@ -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)

View File

@ -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;
}
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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();

View File

@ -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;
}
/**
* 保存或更新记录
* 根据条件判断是新增还是更新,支持幂等性操作

View File

@ -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');
}
}

View File

@ -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');

View File

@ -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 追加监听

View File

@ -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'];

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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);
}

View 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());
}
}
}

View File

@ -68,5 +68,8 @@ class UserLoginListener implements ListenerInterface
} catch (\Throwable $e) {
Log::error('用户登录监听失败,失败原因:' . $e->getMessage());
}
//一号通短信余额检查(监听器内有节流)
event('SmsBalanceCheck');
}
}

View File

@ -23,6 +23,4 @@ return [
// \think\middleware\TraceDebug::class,
//初始化基础中间件
\app\http\middleware\BaseMiddleware::class,
// 多语言支持
\think\middleware\LoadLangPack::class,
];

View File

@ -37,6 +37,12 @@ class UploadStorage extends BaseModel
*/
protected $autoWriteTimestamp = false;
/**
* 关闭框架对 update_time 字段的日期字符串转换,保持 Unix 时间戳
* @var bool
*/
protected $updateTime = false;
/**
* 按驱动隔离存储空间查询
* @param mixed $query

View File

@ -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];
}
/**

View File

@ -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
{

View File

@ -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 中

View File

@ -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

View File

@ -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);

View File

@ -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);
}

View File

@ -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