mcpField
S
sfcc-cartridge-development

sfcc-cartridge-development

github.com/finsilabs开发6076 次安装6 天前更新
代码仓库

关于 sfcc-cartridge-development

面向 Salesforce Commerce Cloud 开发者,指导使用 SFRA 和 B2C Commerce Script API 构建及扩展 cartridge。涵盖控制器、模板、表单、OCAPI/SCAPI 集成和定时作业。

主要功能

  1. 01构建并分层 cartridge
  2. 02创建及扩展 SFRA 控制器
  3. 03编写 ISML 模板与数据模型
  4. 04处理表单和服务端验证
  5. 05集成 OCAPI 与 SCAPI
  6. 06创建定时数据处理作业

适用场景

  1. 01为 SFRA 添加自定义功能
  2. 02扩展现有控制器、模板或模型
  3. 03实现自定义结账或支付流程
  4. 04安排产品或订单数据同步

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill sfcc-cartridge-development

安装后在 Claude Code 中输入/sfcc-cartridge-development确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

tile.json
evals/cartridge-structure-and-scheduled-job/
criteria.json
task.md
evals/controller-extension-and-isml-template/
criteria.json
task.md
evals/ocapi-hooks-and-form-validation/
criteria.json
task.md

详细介绍

SFCC Cartridge 开发

概述

使用 Storefront Reference Architecture(SFRA)、服务器端 JavaScript 控制器、ISML 模板、模型和 B2C Commerce Script API,为 Salesforce Commerce Cloud(SFCC)构建自定义 cartridge。本技能涵盖 cartridge 分层和覆盖机制、使用 server.js 处理路由、表单处理、OCAPI/SCAPI 集成,以及使用 Job Framework 进行计划数据处理。

适用场景

  • 构建扩展 SFRA 功能的自定义功能 cartridge
  • 覆盖或扩展现有 SFRA 控制器、模板或模型
  • 在 SFCC 上实现自定义结账步骤或支付集成
  • 创建用于数据导入/导出(商品 Feed、订单同步)的计划作业
  • 为无头 storefront 构建 OCAPI 钩子或 SCAPI 集成

核心说明

  1. 设置 cartridge 结构和分层

    SFCC 使用 cartridge 路径进行分层。路径中优先级较高的 cartridge 会覆盖优先级较低的 cartridge。自定义 cartridge 可扩展 app_storefront_base:

    int_acme_custom/
    ├── cartridge/
    │   ├── controllers/       # Server-side JS controllers
    │   ├── models/            # Data model wrappers
    │   ├── scripts/           # Business logic helpers
    │   ├── templates/
    │   │   └── default/       # ISML templates
    │   ├── forms/
    │   │   └── default/       # Form definitions (XML)
    │   ├── static/
    │   │   └── default/
    │   │       ├── css/
    │   │       └── js/
    │   └── int_acme_custom.properties  # Cartridge metadata
    └── package.json
    

    在 Business Manager 中设置 cartridge 路径:

    int_acme_custom:app_storefront_base
    

    int_acme_custom.properties:

    ## cartridge.properties
    demandware.cartridges.int_acme_custom.multipleLanguageStorefront=true
    
  2. 创建服务器端控制器

    SFRA 中的控制器使用 server.js 注册路由:

    // controllers/CustomPage.js
    'use strict';
    
    var server = require('server');
    var cache = require('*/cartridge/scripts/middleware/cache');
    var consentTracking = require('*/cartridge/scripts/middleware/consentTracking');
    
    /**
     * CustomPage-Show : Renders a custom content page
     * @name CustomPage-Show
     * @function
     * @memberof CustomPage
     * @param {middleware} - server.middleware.https
     * @param {middleware} - consentTracking.consent
     * @param {middleware} - cache.applyDefaultCache
     * @param {querystringparameter} - cid : content asset ID
     * @param {renders} - isml
     * @param {serverfunction} - get
     */
    server.get('Show',
        server.middleware.https,
        consentTracking.consent,
        cache.applyDefaultCache,
        function (req, res, next) {
            var ContentMgr = require('dw/content/ContentMgr');
            var ContentModel = require('*/cartridge/models/content');
    
            var contentId = req.querystring.cid;
            var apiContent = ContentMgr.getContent(contentId);
    
            if (!apiContent) {
                res.setStatusCode(404);
                res.render('error/notFound');
                return next();
            }
    
            var contentModel = new ContentModel(apiContent);
    
            res.render('custom/contentPage', {
                content: contentModel,
                breadcrumbs: [
                    { htmlValue: 'Home', url: '/' },
                    { htmlValue: contentModel.name, url: '' }
                ]
            });
    
            next();
        }
    );
    
    /**
     * CustomPage-Submit : Handles form POST submissions
     */
    server.post('Submit',
        server.middleware.https,
        function (req, res, next) {
            var Transaction = require('dw/system/Transaction');
            var CustomObjectMgr = require('dw/object/CustomObjectMgr');
    
            var form = req.form;
            var name = form.name;
            var email = form.email;
    
            // Validate input
            if (!name || !email) {
                res.json({ success: false, error: 'Name and email are required.' });
                return next();
            }
    
            try {
                Transaction.wrap(function () {
                    var co = CustomObjectMgr.createCustomObject('AcmeSubmissions', email);
                    co.custom.name = name;
                    co.custom.submittedAt = new Date();
                });
    
                res.json({ success: true, message: 'Submission received.' });
            } catch (e) {
                var Logger = require('dw/system/Logger');
                Logger.error('Submission failed: {0}', e.message);
                res.json({ success: false, error: 'An error occurred. Please try again.' });
            }
    
            next();
        }
    );
    
    module.exports = server.exports();
    
  3. 扩展现有 SFRA 控制器

    使用 server.extend 为现有控制器添加或修改路由:

    // controllers/Cart.js — extending app_storefront_base Cart
    'use strict';
    
    var server = require('server');
    var page = module.superModule;  // Reference to the base Cart controller
    server.extend(page);
    
    /**
     * Cart-Show : Append custom data to the Cart page
     */
    server.append('Show', function (req, res, next) {
        var viewData = res.getViewData();
    
        // Add custom upsell products to the cart page
        var ProductMgr = require('dw/catalog/ProductMgr');
        var ArrayList = require('dw/util/ArrayList');
        var upsells = new ArrayList();
    
        var basket = require('dw/order/BasketMgr').getCurrentBasket();
        if (basket) {
            var items = basket.getAllProductLineItems();
            for (var i = 0; i < items.length; i++) {
                var recommendations = items[i].product.getRecommendations();
                for (var j = 0; j < Math.min(recommendations.length, 2); j++) {
                    upsells.push(recommendations[j].getRecommendedItem());
                }
            }
        }
    
        viewData.upsellProducts = upsells.toArray().slice(0, 4);
        res.setViewData(viewData);
        next();
    });
    
    /**
     * Cart-AddCustomItem : New route added to the Cart controller
     */
    server.post('AddCustomItem', function (req, res, next) {
        var BasketMgr = require('dw/order/BasketMgr');
        var Transaction = require('dw/system/Transaction');
        var ProductMgr = require('dw/catalog/ProductMgr');
    
        var productId = req.form.pid;
        var quantity = parseInt(req.form.quantity, 10) || 1;
        var product = ProductMgr.getProduct(productId);
    
        if (!product || !product.isOnline()) {
            res.json({ error: true, message: 'Product not available.' });
            return next();
        }
    
        var basket = BasketMgr.getCurrentOrNewBasket();
        Transaction.wrap(function () {
            var pli = basket.createProductLineItem(productId, basket.getDefaultShipment());
            pli.setQuantityValue(quantity);
        });
    
        res.json({ success: true, itemCount: basket.productQuantityTotal });
        next();
    });
    
    module.exports = server.exports();
    
  4. 编写 ISML 模板

    <--- templates/default/custom/contentPage.isml --->
    <isdecorate template="common/layout/page">
    
        <isscript>
            var assets = require('*/cartridge/scripts/assets');
            assets.addCss('/css/custom/content.css');
            assets.addJs('/js/custom/content.js');
        </isscript>
    
        <div class="container custom-content-page">
            <div class="row">
                <div class="col-12">
                    <nav aria-label="Breadcrumb">
                        <ol class="breadcrumb">
                            <isloop items="${pdict.breadcrumbs}" var="crumb" status="loopstatus">
                                <isif condition="${loopstatus.last}">
                                    <li class="breadcrumb-item active">${crumb.htmlValue}</li>
                                <iselse/>
                                    <li class="breadcrumb-item">
                                        <a href="${crumb.url}">${crumb.htmlValue}</a>
                                    </li>
                                </isif>
                            </isloop>
                        </ol>
                    </nav>
    
                    <h1>${pdict.content.name}</h1>
                    <div class="content-body">
                        <isprint value="${pdict.content.body}" encoding="off"/>
                    </div>
                </div>
            </div>
        </div>
    
    </isdecorate>
    
  5. 创建数据模型包装器

    // models/content.js
    'use strict';
    
    var URLUtils = require('dw/web/URLUtils');
    
    /**
     * Content model wrapping a dw.content.Content API object
     * @param {dw.content.Content} contentObj - Content API object
     * @constructor
     */
    function ContentModel(contentObj) {
        this.id = contentObj.ID;
        this.name = contentObj.name || contentObj.ID;
        this.body = contentObj.custom.body ? contentObj.custom.body.markup : '';
        this.online = contentObj.online;
        this.url = URLUtils.url('CustomPage-Show', 'cid', contentObj.ID).toString();
        this.pageTitle = contentObj.pageTitle || this.name;
        this.pageDescription = contentObj.pageDescription || '';
        this.pageKeywords = contentObj.pageKeywords || '';
    }
    
    module.exports = ContentModel;
    
  6. 构建用于数据处理的计划作业

    // scripts/jobs/syncInventory.js
    'use strict';
    
    var Status = require('dw/system/Status');
    var Logger = require('dw/system/Logger').getLogger('inventory-sync', 'acme');
    var HTTPClient = require('dw/net/HTTPClient');
    var Transaction = require('dw/system/Transaction');
    var ProductInventoryMgr = require('dw/catalog/ProductInventoryMgr');
    
    /**
     * Job step: Fetch inventory from external ERP and update SFCC
     * @param {dw.util.HashMap} params - Job step parameters
     * @returns {dw.system.Status} - Job status
     */
    function execute(params) {
        var apiUrl = params.get('apiUrl');
        var apiKey = params.get('apiKey');
        var inventoryListId = params.get('inventoryListId') || 'default';
    
        var httpClient = new HTTPClient();
        httpClient.open('GET', apiUrl);
        httpClient.setRequestHeader('Authorization', 'Bearer ' + apiKey);
        httpClient.setRequestHeader('Accept', 'application/json');
        httpClient.setTimeout(30000);
        httpClient.send();
    
        if (httpClient.statusCode !== 200) {
            Logger.error('ERP API returned status {0}', httpClient.statusCode);
            return new Status(Status.ERROR, 'API_ERROR', 'ERP API returned ' + httpClient.statusCode);
        }
    
        var inventory = JSON.parse(httpClient.text);
        var inventoryList = ProductInventoryMgr.getInventoryList(inventoryListId);
    
        if (!inventoryList) {
            return new Status(Status.ERROR, 'LIST_NOT_FOUND', 'Inventory list not found');
        }
    
        var updated = 0;
        var errors = 0;
    
        inventory.items.forEach(function (item) {
            try {
                Transaction.wrap(function () {
                    var record = inventoryList.getRecord(item.sku);
                    if (!record) {
                        record = inventoryList.createRecord(item.sku);
                    }
                    record.setAllocation(item.quantity);
                    if (item.inStockDate) {
                        record.setInStockDate(new Date(item.inStockDate));
                    }
                });
                updated++;
            } catch (e) {
                Logger.error('Failed to update SKU {0}: {1}', item.sku, e.message);
                errors++;
            }
        });
    
        Logger.info('Inventory sync complete: {0} updated, {1} errors', updated, errors);
        return new Status(Status.OK, 'SYNC_COMPLETE', updated + ' records updated');
    }
    
    module.exports.execute = execute;
    

示例

用于创建订单的 OCAPI 钩子
// hooks/order/ocapiHooks.js
'use strict';

var Status = require('dw/system/Status');
var Logger = require('dw/system/Logger').getLogger('ocapi-hooks', 'acme');

/**
 * OCAPI after-POST hook for order creation
 * Called after a new order is placed via OCAPI
 */
exports.afterPOST = function (order) {
    try {
        // Send order data to external analytics
        var HTTPClient = require('dw/net/HTTPClient');
        var httpClient = new HTTPClient();
        httpClient.open('POST', 'https://analytics.acme.com/orders');
        httpClient.setRequestHeader('Content-Type', 'application/json');
        httpClient.send(JSON.stringify({
            orderId: order.orderNo,
            total: order.totalGrossPrice.value,
            currency: order.currencyCode,
            itemCount: order.productLineItems.length,
            customerEmail: order.customerEmail,
        }));

        if (httpClient.statusCode !== 200) {
            Logger.warn('Analytics push failed for order {0}: HTTP {1}',
                order.orderNo, httpClient.statusCode);
        }
    } catch (e) {
        Logger.error('OCAPI hook error: {0}', e.message);
    }

    return new Status(Status.OK);
};

在 hooks.json 中注册:

{
    "hooks": [
        {
            "name": "dw.ocapi.shop.order.afterPOST",
            "script": "./hooks/order/ocapiHooks"
        }
    ]
}
表单定义和服务器端验证

<?xml version="1.0" encoding="UTF-8"?>
<form xmlns="http://www.demandware.com/xml/form/2008-04-19">
    <field formid="name" label="form.contactus.name"
           type="string" mandatory="true" max-length="100"/>
    <field formid="email" label="form.contactus.email"
           type="string" mandatory="true" max-length="254"
           regexp="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"/>
    <field formid="message" label="form.contactus.message"
           type="string" mandatory="true" max-length="2000"/>
    <action formid="submit" label="form.contactus.submit" valid-form="true"/>
</form>
// controllers/ContactUs.js
'use strict';
var server = require('server');

server.get('Show', function (req, res, next) {
    var contactForm = server.forms.getForm('contactus');
    contactForm.clear();
    res.render('contactus/form', { contactForm: contactForm });
    next();
});

server.post('Submit', function (req, res, next) {
    var contactForm = server.forms.getForm('contactus');

    if (contactForm.valid) {
        var Transaction = require('dw/system/Transaction');
        var CustomObjectMgr = require('dw/object/CustomObjectMgr');

        Transaction.wrap(function () {
            var co = CustomObjectMgr.createCustomObject(
                'ContactSubmission',
                require('dw/util/UUIDUtils').createUUID()
            );
            co.custom.name = contactForm.name.value;
            co.custom.email = contactForm.email.value;
            co.custom.message = contactForm.message.value;
        });

        res.json({ success: true });
    } else {
        res.json({
            success: false,
            fields: {
                name: contactForm.name.error || null,
                email: contactForm.email.error || null,
                message: contactForm.message.error || null,
            }
        });
    }
    next();
});

module.exports = server.exports();

最佳实践

  • 遵循 cartridge 分层约定 —— 自定义 cartridge 会覆盖基础 cartridge;使用 module.superModule 扩展控制器,而不是替换控制器
  • 优先使用 server.append,而非 server.replace —— 追加可保留原控制器逻辑和其他 cartridge 扩展;替换会破坏调用链
  • 将所有数据库写入操作包装在 Transaction.wrap() 中 —— SFCC 要求所有持久化变更都使用显式事务;缺少事务会导致静默失败
  • 使用 Script API,而非直接访问数据库 —— SFCC 不支持直接 SQL 访问;始终使用 *Mgr 类(ProductMgr、BasketMgr、OrderMgr)执行数据操作
  • 使用分类日志记录器 —— 使用 Logger.getLogger(category, prefix),以便在 Log Center 中按类别筛选日志消息
  • 切勿将站点专属值硬编码 —— 对 API 密钥和功能开关等可配置值,请使用 Site Preferences(Business Manager 中的自定义站点首选项)
  • 使用 SFCC 沙盒进行测试 —— 部署到暂存或生产环境之前,务必先在沙盒实例上开发和测试
  • 使用 SFCC lint 规则 —— 强制使用 'use strict',并检查控制器中是否缺少 next() 调用;缺少该调用会导致请求挂起

常见问题

问题解决方案
找不到控制器路由(404)确认 cartridge 位于 cartridge 路径中(Business Manager > Sites > Manage Sites > Cartridges),且控制器文件名与路由匹配
module.superModule 返回 null确保基础 cartridge 在 cartridge 路径中的顺序位于自定义 cartridge 之后;顺序很重要
模板更改未显示在 Business Manager 中清除 SFCC 模板缓存(Administration > Sites > Manage Sites > Cache);ISML 模板会被积极缓存
Custom Object 未持久化确保操作位于 Transaction.wrap() 中;检查 Custom Object 类型是否已在 Business Manager 中定义(Administration > Site Development > Custom Objects)
Job 步骤静默失败每个 Job 步骤都应返回一个 Status 对象;失败时返回 Status.ERROR,以便 Job 框架正确报告失败
ISML <isprint> 对 HTML 重复转义对可信 HTML 内容(例如 CMS 正文标记)使用 <isprint> 的 encoding="off";对用户生成的内容使用默认编码

相关技能

  • @erp-integration
  • @ecommerce-caching
  • @ecommerce-seo
  • @pci-dss-compliance
  • @product-data-modeling

评论

支持 Markdown