给 CRMEB 商品中台加一个「课程」商品类型:从建表到后台页面的完整二开
环境:PHP 8.2+、
backend/为 ThinkPHP 多应用结构、表前缀eb_。文中命令均在backend/目录下执行。
一、为什么新增商品形态不该动订单表
CRMEB v8 把「商品」拆成了两层:eb_product_center 是中台主表,只放 name、price、stock、is_show、product_type 这类所有形态都通用的字段;每种形态的专属字段(卡密的卡号池、虚拟商品的兑换码、实物的重量体积)各自落到独立的扩展表里。
backend/modules/README.md 把这套关系写得很直白:
product/(卖什么)×trade/(怎么卖)是两个正交维度,各有独立注册表;- 改变商品形态的实现
ProductTypeHandlerInterface,归product/; - 只发权益、不改变成交的,归
marketing/,靠事件挂载; - 模块之间不可互相引用,跨模块只能走
crmeb/契约或event()。
所以"加一个课程商品"的正确姿势是:新增一个 product 域插件包,实现契约,注册进注册中心。订单表、订单状态机、退款逻辑一行都不用改------因为中台是按能力声明 办事,而不是按 if ($type == 'cardkey') 办事。这一点在契约注释里反复强调,例如 supportsReturn() 上写着"调用方(订单中台等)不得按类型名硬编码判断"。
二、一个产品类型由什么组成
以现有的卡密商品为参照,一个 product 域插件的目录长这样(backend/modules/product/cardkey/):
text
modules/product/cardkey/
├── plugin.json # 插件清单:key/type/path
├── App.php # 注册入口:register 登记处理器,boot 加载事件
├── Installer.php # 安装器:建表、菜单、种子,幂等
├── Handler.php # 产品类型处理器(契约主实现)
├── event.php # 事件监听表
├── handler/
│ ├── CardkeyOrderAdapter.php # 订单流转定义(状态树 + 按钮)
│ └── CardkeyAfterSaleHandler.php
├── controller/admin/ # 后台控制器
├── controller/user/ # 用户端控制器
├── route/admin.php # /adminapi/cardkey/*
├── route/user.php
├── service/ dao/ model/ listener/
App.php 是整个插件的注册枢纽,只有十几行,但三行 register 决定了这个类型能否被中台"看见":
php
class App
{
public static function register(ThinkApp $app): void
{
// 注册产品类型 Handler(ProductTypeRegistry 单例由 app/AppService.php 在启动时注入容器)
$app->make(ProductTypeRegistry::class)->register('cardkey', Handler::class);
// 注册订单流转定义
OrderTypeRegistry::register('cardkey_order', CardkeyOrderAdapter::class);
// 注册售后规则(product_type 维度):卡密仅支持退款,发卡后不可退
AfterSaleRuleRegistry::registerProductType('cardkey', CardkeyAfterSaleHandler::class);
}
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);
}
}
}
}
三处注册分别对应三个问题:商品能不能建成这个类型(ProductTypeRegistry)、这个类型的订单状态怎么翻译成中台的统一状态(OrderTypeRegistry)、退款退货按什么规则走(AfterSaleRuleRegistry)。
契约本身有 32 个方法,看着多,但 crmeb/trade/product/BaseProductTypeHandler.php 给了全量默认实现。卡密这类"无规格、无物流、自动发货"的类型,实际只需要覆盖十来个。
三、七步落地
第 1 步:定表
课程和卡密的结构差异在于:卡密是"预置卡号池、支付后发一张",课程是"一份内容、支付后给用户开通权限"。所以只需要两张表:扩展信息表 + 用户已购记录表。
php
// modules/product/course/Installer.php
protected function runMigrate(): void
{
if (!$this->hasTable('pt_course')) {
$this->exec("CREATE TABLE IF NOT EXISTS `{$this->table('pt_course')}` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`product_id` int NOT NULL DEFAULT 0 COMMENT '关联product_center.id',
`lesson_count` int NOT NULL DEFAULT 0 COMMENT '课时数',
`valid_days` int NOT NULL DEFAULT 0 COMMENT '有效期天数 0永久',
`teacher` varchar(64) NOT NULL DEFAULT '' COMMENT '讲师',
`usage_tip` text COMMENT '学习说明',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET={$this->charset} COLLATE={$this->collation} COMMENT='课程商品扩展表';");
}
if (!$this->hasTable('pt_course_order')) {
$this->exec("CREATE TABLE IF NOT EXISTS `{$this->table('pt_course_order')}` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL DEFAULT '' COMMENT '关联order_center.order_no',
`product_id` int NOT NULL DEFAULT 0 COMMENT '商品ID',
`uid` int NOT NULL DEFAULT 0 COMMENT '用户ID',
`status` varchar(32) NOT NULL DEFAULT 'pending_payment' COMMENT '业务状态',
`open_time` int NOT NULL DEFAULT 0 COMMENT '开通时间',
`expire_time` int NOT NULL DEFAULT 0 COMMENT '到期时间 0永久',
`refund_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '退款状态',
`add_time` int NOT NULL DEFAULT 0 COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET={$this->charset} COLLATE={$this->collation} COMMENT='课程订单表';");
}
}
两个细节必须照抄:表名不带前缀 ($this->table() 内部拼 config('database.connections.mysql.prefix'),默认 eb_),建表用 exec() 而不是事务 。基类 install() 的注释已经写明原因:MySQL 执行 DDL 会隐式提交,DDL 必须在事务外,菜单和种子才放事务里。
安装器还有三个身份方法不能省:
php
public function getKey(): string { return 'product_course'; } // 必须与 plugin.json 的 key 完全一致
public function getName(): string { return '课程商品'; }
public function getType(): string { return 'product_type'; }
getKey() 和清单 key 对不上,插件管理里会出现两条记录,卸载时删不掉旧那条。另外安装、启用、禁用、重装都可能被反复触发,runMigrate() 里一律 hasTable() + CREATE TABLE IF NOT EXISTS 双保险,菜单走基类 insertMenus() 的幂等写入,不要自己写 INSERT。
第 2 步:生成骨架
bash
cd backend
php think make:module course --type=product --label=课程商品
命令会校验模块名(小写字母开头、仅含小写字母/数字/下划线),并在 modules/course/ 下生成 plugin.json、App.php、event.php、Installer.php、README.md,以及 config/route/controller/service/dao/model/listener/crontab 八个分层目录。
这里有个必须手改的坑 :命令固定写到 modules/{name}/,支持 --type 但不会替你分层。而 modules/README.md 的约定是 modules/{域}/{name}/。所以生成后要挪目录,并同步改两处身份:
bash
mv modules/course modules/product/course
json
{
"key": "product_course",
"name": "课程商品",
"description": "课程商品产品类型,支持课时、有效期与购买后自动开通",
"version": "1.0.0",
"author": "CRMEB Team",
"type": "product_type",
"path": "modules/product/course",
"require": {
"crmeb": "^8.0",
"php": "^8.0"
}
}
key 用下划线格式(对齐 product_cardkey),path 必须与真实目录一致,命名空间从 modules\course 改成 modules\product\course。这三样任何一处对不上,php think plugin:manifest-check 都会拦下来。
第 3 步:写 Handler
课程的核心特征:不需要物流、不支持到店自提、不支持退货(内容已交付)、无规格选择。这些都不需要用代码"判断",而是用返回值声明。
php
namespace modules\product\course;
use crmeb\trade\order\aftersale\AfterSaleHandlerInterface;
use crmeb\trade\product\BaseProductTypeHandler;
class Handler extends BaseProductTypeHandler
{
public function getTypeKey(): string
{
return 'course';
}
public function getTypeName(): string
{
return '课程商品';
}
/** 与 CourseOrderAdapter::getSourceType() 保持一致 */
public function getOrderSourceType(): string
{
return 'course_order';
}
/** 课程只支持普通购买,不参与秒杀/拼团/砍价 */
public function canCombineWith(string $tradeMode): bool
{
return in_array($tradeMode, ['normal']);
}
public function isVirtual(): bool
{
return true;
}
public function supportsReturn(): bool
{
return false;
}
/** 无规格矩阵、只读回显、无必选项 */
public function getSelectionSpec(): array
{
return [
'selection_model' => 'none',
'confirm_window' => 'readonly',
'required_selections' => [],
'cart_unit' => 'item',
];
}
public function getCartStrategy(): array
{
return ['cart_mode' => 'cart', 'multi_item' => false, 'button_text' => ''];
}
public function getCompatibility(): array
{
return [
'supported_trade_modes' => ['normal'],
'requires_shipping' => false,
'supports_pickup' => false,
'supports_after_sale' => true,
];
}
}
库存要自己实现,因为基类的 decStock() / incStock() 是直接抛 RuntimeException 的------这是故意的,避免静默卖出超量。课程的"库存"就是中台主表的 stock,直接加减即可;如果业务上课程不限量,返回 true 但要保证 getSkuInfo() 返回的 stock 足够大。
履约是课程和实物最本质的区别。实物走 fulfillOrder() 返回 true 等商家发货;课程要在支付后自动开通:
php
public function fulfillOrder(string $orderNo, array $orderInfo): bool
{
// 幂等:已开通直接返回
$record = app(CourseOrderDao::class)->getOne(['order_no' => $orderNo]);
if ($record && (int)$record['open_time'] > 0) {
return true;
}
app(CourseOrderServices::class)->open($orderNo, $orderInfo);
return true;
}
售后规则用独立类注册,不要和 Handler 混在一起------AfterSaleRuleRegistry::registerProductType('course', ...) 拿的是 AfterSaleHandlerInterface 实现,中台在退款判定时按产品类型取它,而不是问 Handler。
第 4 步:订单适配器(状态树)
中台不认识 pending_payment、opened 这些课程自己的状态,需要一个适配器翻译成统一状态码。
php
namespace modules\product\course\handler;
use crmeb\trade\order\type\BaseOrderTypeAdapter;
use crmeb\trade\order\type\Button;
use crmeb\trade\order\type\OrderFlowNode;
use modules\product\course\model\CourseOrderModel;
class CourseOrderAdapter extends BaseOrderTypeAdapter
{
public const EXT_STATUS_PENDING_PAYMENT = 'pending_payment';
public const EXT_STATUS_OPENED = 'opened';
public const EXT_STATUS_CANCELED = 'canceled';
public const EXT_STATUS_REFUNDED = 'refunded';
public static function getSourceType(): string
{
return 'course_order';
}
public static function getOrderCategory(): string
{
return 'goods';
}
public static function getOrderCategoryLabel(): string
{
return '商品订单';
}
public static function getModelClass(): string
{
return CourseOrderModel::class;
}
protected function defineStatusTree(): array
{
return [
OrderFlowNode::make(self::EXT_STATUS_PENDING_PAYMENT)
->centerStatus(self::STATUS_PENDING_PAYMENT)
->label('待支付')
->userButtons([
Button::make('取消订单', 'cancelOrder'),
Button::primary('去支付', 'payOrder'),
]),
OrderFlowNode::make(self::EXT_STATUS_OPENED)
->centerStatus(self::STATUS_COMPLETED)
->label('已开通')
->badge('opened', '已开通', '课程权限已生效')
->userButtons([
Button::primary('去学习', 'gotoCourse'),
]),
// ... canceled / after_sale / refunded 同理
];
}
public function resolveStatusCode(array $sourceOrder): string
{
// 取消 > 退款 > 已开通 > 待支付,顺序不能乱
return match ((string)($sourceOrder['status'] ?? '')) {
self::EXT_STATUS_CANCELED => self::STATUS_CANCELED,
self::EXT_STATUS_REFUNDED => self::STATUS_REFUNDED,
self::EXT_STATUS_OPENED => self::STATUS_COMPLETED,
default => self::STATUS_PENDING_PAYMENT,
};
}
}
状态树是这套设计里最值得抄的部分:按钮不是写死在 Vue 里的,而是挂在节点上。中台输出订单详情时按当前节点取 adminButtons / userButtons,前端只管渲染。加新类型不用改前端。
第 5 步:售后规则单独成类
售后判定不写在 Handler 里,而是由 AfterSaleRuleRegistry::registerProductType() 注册一个独立类。基类 BaseAfterSaleHandler 把 afterApply、afterAudit、afterReturn、afterRefund、afterCancel 这些生命周期钩子都做成了空实现,所以真正要写的只有 getRule():
php
namespace modules\product\course\handler;
use crmeb\trade\order\aftersale\BaseAfterSaleHandler;
class CourseAfterSaleHandler extends BaseAfterSaleHandler
{
/**
* @return array{allow:bool,reject_reason:string,available_types:array,refund_target:string,max_refund_num:int,deadline:int}
*/
public function getRule(array $orderCenter, array $sourceOrder): array
{
// 权限已开通 = 内容已交付,只允许退款不允许退货退款
$opened = (int)($sourceOrder['open_time'] ?? 0) > 0;
return [
'allow' => !$opened,
'reject_reason' => $opened ? '课程已开通,不支持退款' : '',
'available_types' => ['refund_only'],
'refund_target' => 'user',
'max_refund_num' => 0, // 0 表示不单独限制数量
'deadline' => 7, // 天数
];
}
}
第二个参数 $sourceOrder 不是中台订单,而是你扩展表里的那条记录 ------它由 Handler 的 getOrderExtend() 提供。这是这套契约里少见的"两段式"取值:售后规则要读业务字段(开通时间、有效期),又不能直接查中台的 eb_order_center,于是让产品类型自己交出来。getOrderExtend() 返回空数组,getRule() 里就永远读不到 open_time,$opened 永远是 false,退款会被一路放行------这类"字段静默缺失"很难在联调里发现,写完记得先打日志确认一次。
第 6 步:路由与权限
route/admin.php 不需要手动 include,放进 route/ 目录就会被扫描。但中间件、real_name、路由分组一个都不能省:
php
use think\facade\Route;
use app\http\middleware\AllowOriginMiddleware;
use app\adminapi\middleware\AdminAuthTokenMiddleware;
use modules\product\course\controller\admin\ProductController;
Route::group('course', function () {
Route::group(function () {
Route::get('product/list', [ProductController::class, 'list'])
->option(['real_name' => '课程商品列表']);
Route::post('product', [ProductController::class, 'save'])
->option(['real_name' => '创建课程商品']);
Route::get('product/:id', [ProductController::class, 'read'])
->option(['real_name' => '课程商品详情']);
})->option(['parent' => 'course', 'cate_name' => '课程商品管理']);
})->middleware([
AllowOriginMiddleware::class,
AdminAuthTokenMiddleware::class,
])->option(['mark' => 'course', 'mark_name' => '课程商品管理']);
菜单在 Installer::getMenuConfig() 里声明,parent_key 挂到已有的商品父菜单 core:admin-product,并且每条可见菜单都要配一条 auth_type => 3 的隐藏菜单给接口鉴权用:
php
[
'parent_key' => 'core:admin-product',
'menu_name' => '课程商品',
'controller' => 'courseProduct',
'action' => 'index',
'menu_path' => '/product/course',
'unique_auth' => 'admin-course-product',
'icon' => 'CRMEB-196-shangpinguanli-xianxing',
'sort' => 40,
'is_show' => 1,
'is_show_path'=> 1,
'access' => 1,
'auth_type' => 1,
],
[
'pid' => '@课程商品',
'menu_name' => '创建课程商品',
'controller' => 'courseProduct',
'action' => 'save',
'api_url' => 'course/product',
'methods' => 'POST',
'unique_auth' => 'admin-course-product-save',
'auth_type' => 3,
],
这里的 @课程商品 是基类 BaseModuleInstaller 认的父级引用写法(按菜单名找 pid),照抄卡密模块即可。
第 7 步:前台入口打通
后台商品保存走的是中台统一入口,app/services/product/CenterProductServices.php 里有一段"先问类型、再走遗留逻辑"的分发:
php
$productType = $data['product_type'] ?? 'normal';
$registry = app()->make(ProductTypeRegistry::class);
// has 前置判断避免未注册类型回退 normal;Handler 返回 null 表示该类型无模块化保存
if ($registry->has($productType)) {
$result = $registry->get($productType)->saveFromAdmin($id, $data);
if ($result !== null) {
return $result;
}
}
所以要覆盖 saveFromAdmin(),把主表 + 中台表 + 扩展表写在一个事务里;同时覆盖 getAdminExtendInfo() 做编辑回显。返回 null 意味着"我不管,让中台按实物逻辑存",那你的 pt_course 就永远是空表。
管理端的类型下拉不用改前端:CenterProductController::typeList() 直接返回 ProductTypeRegistry::allInfo(),注册即出现。
页面部分按插件 UMD 模式做,目录 frontend/admin/src/pages/plugin/course/,需要两个文件:ext.json 声明页面清单,index.umd.js 作为构建入口。
json
{
"key": "course",
"title": "课程商品",
"version": "1.0.0",
"pages": [
{ "path": "list", "title": "课程商品列表" },
{ "path": "add", "title": "添加/编辑课程商品" }
]
}
bash
cd frontend/admin
npm run build:ext -- --ext course
产物在 dist/static/extensions/course/,由 PageContainer 运行时加载。只跑 npm run build 是不够的------主包不含扩展加载器,插件页面会显示"扩展未安装"。
还要在 frontend/admin/src/pages/plugin/course/routes.js 里把页面挂到静态路由上,meta.auth 必须与安装器里的 unique_auth 一字不差:
js
const pageContainer = () => import('@/pages/system/plugin/PageContainer');
export default [
{
path: routePre + '/product/course/list',
meta: { auth: ['admin-course-product-list'], title: '课程商品', keepAlive: true },
component: () => import('@/layout'),
children: [
{
path: '',
name: 'courseProductList',
component: pageContainer,
props: { typeKey: 'course', section: 'course', action: 'list' },
meta: { auth: ['admin-course-product-list'], title: '课程商品', keepAlive: true },
},
],
},
];
props 里的 typeKey / section / action 三个值是 UMD 包的入口参数,PageContainer 拿它去 ext.json 的 pages 里找对应页面。改扩展名、改页面 key、改菜单路径,这三处任何一处不一致,表现都是"菜单能点,页面白屏"。
四、验收:别只点页面
bash
cd backend
php think plugin:manifest-check # 清单与目录身份
php think registry:check # 四大注册中心一致性
php think arch:check # 三层依赖
php think event:check # 事件监听表
registry:check 会替你查三件容易忘的事:getOrderSourceType() 声明的 course_order 是否真的注册了适配器、有没有注册 product_type 维度的售后规则、getCompatibility() 里声明的交易模式是否存在。第二项缺失的表现很隐蔽------订单默认拒绝售后,而不是报错。
然后手动走一遍:后台装插件 → 建课程 → 用户端下单 → 支付 → 看是否自动开通 → 申请售后被拒 → 取消订单。涉及长驻进程(timer / workerman / queue)的改动,改完必须重启,否则注册表还是旧的。
五、几个真踩过的坑
- 忘了注册就被静默降级 。
ProductTypeRegistry::get()在类型未注册时会回退到normal,商品照样能建,但发货走实物流程。只有在normal也没注册时才抛RuntimeException。所以"能跑"不代表"注册对了"。 - 菜单三层必须同时生效 :前端静态路由(
routes.js的meta.auth)、eb_system_menus记录、角色接口权限。少一层就是"菜单点得开、按钮 403"。 - 表名别手写
eb_。二开环境的前缀可能不是eb_,$this->table()会读配置。 - 脚手架生成的骨架要搬家 。
make:module只写到modules/{name},product 域需要modules/product/{name},plugin.json的path和命名空间都得跟着改。 - 不要动
app/。所有差异都应该收敛在 Handler 的返回值里;一旦在CenterProductServices里加if ($type === 'course'),下次升级就是冲突。
按这套做法,一个新产品类型的工作量基本落在"扩展表设计 + 履约逻辑 + 状态树"这三块,其余都是填返回值。这也是这套中台最实在的地方:扩展成本集中在你真正有差异的业务上,而不是散在订单主流程里。