关于 sfcc-cartridge-development
面向 Salesforce Commerce Cloud 开发者,指导使用 SFRA 和 B2C Commerce Script API 构建及扩展 cartridge。涵盖控制器、模板、表单、OCAPI/SCAPI 集成和定时作业。
主要功能
- 01构建并分层 cartridge
- 02创建及扩展 SFRA 控制器
- 03编写 ISML 模板与数据模型
- 04处理表单和服务端验证
- 05集成 OCAPI 与 SCAPI
- 06创建定时数据处理作业
适用场景
- 01为 SFRA 添加自定义功能
- 02扩展现有控制器、模板或模型
- 03实现自定义结账或支付流程
- 04安排产品或订单数据同步
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill sfcc-cartridge-development安装后在 Claude Code 中输入/sfcc-cartridge-development确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
tile.jsonevals/cartridge-structure-and-scheduled-job/criteria.jsontask.mdevals/controller-extension-and-isml-template/criteria.jsontask.mdevals/ocapi-hooks-and-form-validation/criteria.jsontask.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 集成
核心说明
-
设置 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_baseint_acme_custom.properties:## cartridge.properties demandware.cartridges.int_acme_custom.multipleLanguageStorefront=true -
创建服务器端控制器
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(); -
扩展现有 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(); -
编写 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> -
创建数据模型包装器
// 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; -
构建用于数据处理的计划作业
// 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