给 CRMEB v8 商品中台加一个「课程」商品类型

给 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)的改动,改完必须重启,否则注册表还是旧的。

五、几个真踩过的坑

  1. 忘了注册就被静默降级 。ProductTypeRegistry::get() 在类型未注册时会回退到 normal,商品照样能建,但发货走实物流程。只有在 normal 也没注册时才抛 RuntimeException。所以"能跑"不代表"注册对了"。
  2. 菜单三层必须同时生效 :前端静态路由(routes.js 的 meta.auth)、eb_system_menus 记录、角色接口权限。少一层就是"菜单点得开、按钮 403"。
  3. 表名别手写 eb_ 。二开环境的前缀可能不是 eb_,$this->table() 会读配置。
  4. 脚手架生成的骨架要搬家 。make:module 只写到 modules/{name},product 域需要 modules/product/{name},plugin.json 的 path 和命名空间都得跟着改。
  5. 不要动 app/ 。所有差异都应该收敛在 Handler 的返回值里;一旦在 CenterProductServices 里加 if ($type === 'course'),下次升级就是冲突。

按这套做法,一个新产品类型的工作量基本落在"扩展表设计 + 履约逻辑 + 状态树"这三块,其余都是填返回值。这也是这套中台最实在的地方:扩展成本集中在你真正有差异的业务上,而不是散在订单主流程里。

相关推荐
evoxwht2 小时前
给 CRMEB V8 给 MCP 服务加一个分销工具
php
IT召唤狮14 小时前
开源企业级零信任项目Pangolin,零信任能挡住PHP核弹漏洞吗?
开发语言·开源·php
大鹏说大话19 小时前
PHP 大文件上传解决方案:分片上传完整思路
开发语言·php
niucloud-admin20 小时前
NIUCLOUD PHP SAAS上门家政使用文档--服务订单:订单回访
php
大黄说说1 天前
Composer 常见报错汇总:镜像、依赖冲突、环境坑一站式排查
php·composer
安易算力1 天前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
程序员大雄学编程1 天前
Linux 磁盘满了怎么排查?df / du / find 三步定位大文件
linux·运维·php
fengci.1 天前
CTF2困难部分
开发语言·网络·python·安全·php
大黄说说1 天前
PHP 异步怎么实现?Swoole、Workerman 使用场景对比
开发语言·php·swoole