feat: finalize Dreame Shopify app configuration
This commit is contained in:
parent
cb2865baf5
commit
35a339fe41
|
|
@ -1,12 +1,12 @@
|
||||||
SHOPIFY_API_KEY=
|
SHOPIFY_API_KEY=
|
||||||
SHOPIFY_API_SECRET=
|
SHOPIFY_API_SECRET=
|
||||||
SHOPIFY_APP_URL=
|
SHOPIFY_APP_URL=
|
||||||
SCOPES=read_orders,read_metaobjects
|
SCOPES=read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions
|
||||||
DATABASE_URL="file:./dev.sqlite"
|
DATABASE_URL="file:./dev.sqlite"
|
||||||
THIRD_PARTY_MODE=mock
|
THIRD_PARTY_MODE=mock
|
||||||
THIRD_PARTY_TIMEOUT_MS=5000
|
THIRD_PARTY_TIMEOUT_MS=5000
|
||||||
LOG_RETENTION_DAYS=7
|
LOG_RETENTION_DAYS=7
|
||||||
METAOBJECT_TYPE=dreame_activity
|
METAOBJECT_TYPE=$app:dreame_activity
|
||||||
METAOBJECT_HANDLE=dreame-activity-config
|
METAOBJECT_HANDLE=dreame-activity-config
|
||||||
DREAME_MOCK_STATUS_CODE=0
|
DREAME_MOCK_STATUS_CODE=0
|
||||||
DREAME_MOCK_STATUS_ACTIVE=true
|
DREAME_MOCK_STATUS_ACTIVE=true
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -14,7 +14,7 @@ This project follows:
|
||||||
2. Checkout UI Extension target `purchase.thank-you.block.render` loads.
|
2. Checkout UI Extension target `purchase.thank-you.block.render` loads.
|
||||||
3. Extension gets `orderId`, `orderNumber`, `locale`, and a Shopify session token.
|
3. Extension gets `orderId`, `orderNumber`, `locale`, and a Shopify session token.
|
||||||
4. Extension calls `POST /api/action/activity/check`.
|
4. Extension calls `POST /api/action/activity/check`.
|
||||||
5. Backend validates the checkout session token, queries order data through Admin GraphQL, reads the `dreame_activity` Metaobject config, checks order-level idempotency, calls Dreame status API, then calls Dreame push API.
|
5. Backend validates the checkout session token, queries order data through Admin GraphQL, reads the `$app:dreame_activity` Metaobject config, checks order-level idempotency, calls Dreame status API, then calls Dreame push API.
|
||||||
6. On success, backend returns `success`, `redirectUrl`, and localized `buttonText`.
|
6. On success, backend returns `success`, `redirectUrl`, and localized `buttonText`.
|
||||||
7. Extension renders a button and opens `redirectUrl` in the current window.
|
7. Extension renders a button and opens `redirectUrl` in the current window.
|
||||||
|
|
||||||
|
|
@ -49,12 +49,12 @@ Copy `.env.example` to `.env` and fill Shopify credentials:
|
||||||
SHOPIFY_API_KEY=
|
SHOPIFY_API_KEY=
|
||||||
SHOPIFY_API_SECRET=
|
SHOPIFY_API_SECRET=
|
||||||
SHOPIFY_APP_URL=
|
SHOPIFY_APP_URL=
|
||||||
SCOPES=read_orders,read_metaobjects
|
SCOPES=read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions
|
||||||
DATABASE_URL="file:./dev.sqlite"
|
DATABASE_URL="file:./dev.sqlite"
|
||||||
THIRD_PARTY_MODE=mock
|
THIRD_PARTY_MODE=mock
|
||||||
THIRD_PARTY_TIMEOUT_MS=5000
|
THIRD_PARTY_TIMEOUT_MS=5000
|
||||||
LOG_RETENTION_DAYS=7
|
LOG_RETENTION_DAYS=7
|
||||||
METAOBJECT_TYPE=dreame_activity
|
METAOBJECT_TYPE=$app:dreame_activity
|
||||||
METAOBJECT_HANDLE=dreame-activity-config
|
METAOBJECT_HANDLE=dreame-activity-config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -62,11 +62,15 @@ METAOBJECT_HANDLE=dreame-activity-config
|
||||||
|
|
||||||
## Metaobject Configuration
|
## Metaobject Configuration
|
||||||
|
|
||||||
Create one Metaobject definition:
|
The app-owned Metaobject definition is versioned in `shopify.app.toml`:
|
||||||
|
|
||||||
- Type: `dreame_activity`
|
- TOML key: `[metaobjects.app.dreame_activity]`
|
||||||
|
- Admin API type: `$app:dreame_activity`
|
||||||
- Suggested handle: `dreame-activity-config`
|
- Suggested handle: `dreame-activity-config`
|
||||||
- Storefront access: `NONE`
|
- Admin access: `merchant_read_write`
|
||||||
|
- Storefront access: `none`
|
||||||
|
|
||||||
|
Use `METAOBJECT_TYPE=dreame_activity` only if the shop already uses a legacy merchant-owned Metaobject definition.
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
|
|
||||||
|
|
@ -77,6 +81,9 @@ Fields:
|
||||||
| `api_secret` | `single_line_text_field` | yes | Backend only; never expose to extension |
|
| `api_secret` | `single_line_text_field` | yes | Backend only; never expose to extension |
|
||||||
| `status_api_url` | `url` | yes | Activity status API |
|
| `status_api_url` | `url` | yes | Activity status API |
|
||||||
| `push_api_url` | `url` | yes | Activity push API |
|
| `push_api_url` | `url` | yes | Activity push API |
|
||||||
|
| `status_success_code` | `number_integer` | no | Status API success code; default `0` |
|
||||||
|
| `status_active_required` | `boolean` | no | Whether Status API must return `data.status=true`; default `true` |
|
||||||
|
| `push_success_code` | `number_integer` | no | Push API success code; default `0` |
|
||||||
| `redirect_url` | `url` | yes | Button target URL |
|
| `redirect_url` | `url` | yes | Button target URL |
|
||||||
| `button_text` | `json` | yes | Locale map |
|
| `button_text` | `json` | yes | Locale map |
|
||||||
| `shop` | `single_line_text_field` | no | Overrides `properties.shop`; fallback is myshopify domain |
|
| `shop` | `single_line_text_field` | no | Overrides `properties.shop`; fallback is myshopify domain |
|
||||||
|
|
@ -101,7 +108,7 @@ SHOPIFY_ADMIN_ACCESS_TOKEN=shpat_xxx \
|
||||||
npx tsx scripts/setup-metaobject.ts
|
npx tsx scripts/setup-metaobject.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
The optional script requires write scopes such as `write_metaobjects` and `write_metaobject_definitions`. It is not needed if the Metaobject is created manually in Shopify Admin.
|
The optional script upserts the singleton Metaobject entry. The definition itself is managed through `shopify.app.toml` and applied by Shopify CLI during `shopify app dev` or `shopify app deploy`.
|
||||||
|
|
||||||
## API Contract
|
## API Contract
|
||||||
|
|
||||||
|
|
@ -170,6 +177,7 @@ Default mock behavior:
|
||||||
|
|
||||||
- Status API returns `code=0` and `data.status=true`.
|
- Status API returns `code=0` and `data.status=true`.
|
||||||
- Push API returns `code=0`.
|
- Push API returns `code=0`.
|
||||||
|
- Live success criteria can be overridden through Metaobject fields `status_success_code`, `status_active_required`, and `push_success_code`.
|
||||||
|
|
||||||
Useful mock environment variables:
|
Useful mock environment variables:
|
||||||
|
|
||||||
|
|
@ -226,6 +234,10 @@ network_access = true
|
||||||
|
|
||||||
Set the extension setting `app_url` to the public HTTPS backend URL. During `shopify app dev`, this is the tunnel URL produced by Shopify CLI.
|
Set the extension setting `app_url` to the public HTTPS backend URL. During `shopify app dev`, this is the tunnel URL produced by Shopify CLI.
|
||||||
|
|
||||||
|
`purchase.thank-you.block.render` is a valid latest Thank You Page block target. Merchants can control placement in the checkout and accounts editor; changing to a static target such as `purchase.thank-you.header.render-after` requires TOML/code review and redeployment.
|
||||||
|
|
||||||
|
The extension sets `default_placement = "ORDER_STATUS1"` so the first suggested placement is above the order status card. Merchants can still move the block in the checkout and accounts editor.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
After dependencies are installed:
|
After dependencies are installed:
|
||||||
|
|
@ -239,7 +251,8 @@ Current test coverage:
|
||||||
|
|
||||||
- `test/signature.test.ts`: MD5 signature and single-serialization body contract.
|
- `test/signature.test.ts`: MD5 signature and single-serialization body contract.
|
||||||
- `test/idempotency.test.ts`: same order/activity does not push twice and still returns button data.
|
- `test/idempotency.test.ts`: same order/activity does not push twice and still returns button data.
|
||||||
- `test/activity-flow.test.ts`: inactive status, non-zero status code, successful push payload shape, push failure, missing email, missing customer ID.
|
- `test/config.test.ts`: app-owned Metaobject default type and legacy type override.
|
||||||
|
- `test/activity-flow.test.ts`: inactive status, non-zero status code, configurable success codes, configurable status active policy, successful push payload shape, push failure, missing email, missing customer ID.
|
||||||
|
|
||||||
## Deployment Steps
|
## Deployment Steps
|
||||||
|
|
||||||
|
|
@ -252,7 +265,7 @@ This task does not deploy. For production deployment later:
|
||||||
5. Run `shopify app deploy`.
|
5. Run `shopify app deploy`.
|
||||||
6. Publish the Checkout UI Extension.
|
6. Publish the Checkout UI Extension.
|
||||||
7. Configure the extension `app_url` setting.
|
7. Configure the extension `app_url` setting.
|
||||||
8. Create the `dreame_activity` Metaobject in the production shop.
|
8. Deploy the app-owned `$app:dreame_activity` definition and upsert the `dreame-activity-config` entry.
|
||||||
9. Switch `THIRD_PARTY_MODE=live`.
|
9. Switch `THIRD_PARTY_MODE=live`.
|
||||||
10. Run end-to-end validation with real test orders and third-party test endpoints.
|
10. Run end-to-end validation with real test orders and third-party test endpoints.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default function Index() {
|
||||||
Third party mode: <strong>{data.mode}</strong>
|
Third party mode: <strong>{data.mode}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Configure Metaobject <code>dreame_activity</code>, install the Checkout UI Extension, and use mock mode for
|
Configure Metaobject <code>$app:dreame_activity</code>, install the Checkout UI Extension, and use mock mode for
|
||||||
local validation before live integration.
|
local validation before live integration.
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ export type ActivityConfig = {
|
||||||
apiSecret: string;
|
apiSecret: string;
|
||||||
statusApiUrl: string;
|
statusApiUrl: string;
|
||||||
pushApiUrl: string;
|
pushApiUrl: string;
|
||||||
|
statusSuccessCode?: number;
|
||||||
|
statusActiveRequired?: boolean;
|
||||||
|
pushSuccessCode?: number;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
buttonText: ButtonTextMap;
|
buttonText: ButtonTextMap;
|
||||||
shop?: string;
|
shop?: string;
|
||||||
|
|
@ -149,7 +152,7 @@ export async function runActivityCheck(request: ActivityRequest, deps: ActivityD
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusResult = await deps.callStatus(config);
|
const statusResult = await deps.callStatus(config);
|
||||||
if (!isActiveStatus(statusResult)) {
|
if (!isActiveStatus(statusResult, config)) {
|
||||||
await deps.log(
|
await deps.log(
|
||||||
failureLog(
|
failureLog(
|
||||||
request.shop,
|
request.shop,
|
||||||
|
|
@ -181,7 +184,7 @@ export async function runActivityCheck(request: ActivityRequest, deps: ActivityD
|
||||||
};
|
};
|
||||||
|
|
||||||
const pushResult = await deps.callPush(config, pushPayload);
|
const pushResult = await deps.callPush(config, pushPayload);
|
||||||
if (!isSuccessfulPush(pushResult)) {
|
if (!isSuccessfulPush(pushResult, config)) {
|
||||||
await deps.log(
|
await deps.log(
|
||||||
failureLog(
|
failureLog(
|
||||||
request.shop,
|
request.shop,
|
||||||
|
|
@ -242,12 +245,20 @@ export function extractLegacyId(orderId: string): string {
|
||||||
return orderId.split("/").pop() || orderId;
|
return orderId.split("/").pop() || orderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveStatus(result: ThirdPartyResult<StatusResponse>): boolean {
|
function isActiveStatus(result: ThirdPartyResult<StatusResponse>, config: ActivityConfig): boolean {
|
||||||
return result.httpOk && result.body.code === 0 && result.body.data?.status === true;
|
if (!result.httpOk || result.body.code !== (config.statusSuccessCode ?? 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.statusActiveRequired === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.body.data?.status === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSuccessfulPush(result: ThirdPartyResult<PushResponse>): boolean {
|
function isSuccessfulPush(result: ThirdPartyResult<PushResponse>, config: ActivityConfig): boolean {
|
||||||
return result.httpOk && result.body.code === 0;
|
return result.httpOk && result.body.code === (config.pushSuccessCode ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function failureLog(
|
function failureLog(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const METAOBJECT_QUERY = `#graphql
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function getActivityConfig(admin: AdminGraphqlClient): Promise<ActivityConfig> {
|
export async function getActivityConfig(admin: AdminGraphqlClient): Promise<ActivityConfig> {
|
||||||
const type = process.env.METAOBJECT_TYPE || "dreame_activity";
|
const type = process.env.METAOBJECT_TYPE || "$app:dreame_activity";
|
||||||
const handle = process.env.METAOBJECT_HANDLE || "dreame-activity-config";
|
const handle = process.env.METAOBJECT_HANDLE || "dreame-activity-config";
|
||||||
const response = await admin.graphql(METAOBJECT_QUERY, { variables: { type, handle } });
|
const response = await admin.graphql(METAOBJECT_QUERY, { variables: { type, handle } });
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
|
|
@ -48,6 +48,9 @@ export async function getActivityConfig(admin: AdminGraphqlClient): Promise<Acti
|
||||||
apiSecret: requireString(values.api_secret, "api_secret"),
|
apiSecret: requireString(values.api_secret, "api_secret"),
|
||||||
statusApiUrl: requireString(values.status_api_url, "status_api_url"),
|
statusApiUrl: requireString(values.status_api_url, "status_api_url"),
|
||||||
pushApiUrl: requireString(values.push_api_url, "push_api_url"),
|
pushApiUrl: requireString(values.push_api_url, "push_api_url"),
|
||||||
|
statusSuccessCode: parseOptionalNumber(values.status_success_code, "status_success_code"),
|
||||||
|
statusActiveRequired: parseOptionalBoolean(values.status_active_required, "status_active_required"),
|
||||||
|
pushSuccessCode: parseOptionalNumber(values.push_success_code, "push_success_code"),
|
||||||
redirectUrl: requireString(values.redirect_url, "redirect_url"),
|
redirectUrl: requireString(values.redirect_url, "redirect_url"),
|
||||||
buttonText,
|
buttonText,
|
||||||
shop: values.shop || undefined,
|
shop: values.shop || undefined,
|
||||||
|
|
@ -75,3 +78,32 @@ function parseButtonText(value: string | null | undefined): Record<string, strin
|
||||||
|
|
||||||
return Object.fromEntries(entries);
|
return Object.fromEntries(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumber(value: string | null | undefined, key: string): number | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new Error(`Metaobject field ${key} must be a number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalBoolean(value: string | null | undefined, key: string): boolean | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (["true", "1", "yes"].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["false", "0", "no"].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Metaobject field ${key} must be a boolean`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const shopify = shopifyApp({
|
||||||
apiKey: process.env.SHOPIFY_API_KEY || "",
|
apiKey: process.env.SHOPIFY_API_KEY || "",
|
||||||
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
||||||
apiVersion: "2026-01" as never,
|
apiVersion: "2026-01" as never,
|
||||||
scopes: (process.env.SCOPES || "read_orders,read_metaobjects").split(","),
|
scopes: (process.env.SCOPES || "read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions").split(","),
|
||||||
appUrl: process.env.SHOPIFY_APP_URL || "",
|
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||||
authPathPrefix: "/auth",
|
authPathPrefix: "/auth",
|
||||||
sessionStorage: new PrismaSessionStorage(db),
|
sessionStorage: new PrismaSessionStorage(db),
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ shopify app config link --config production --client-id <production-client-id>
|
||||||
application_url = "https://<public-backend-domain>"
|
application_url = "https://<public-backend-domain>"
|
||||||
|
|
||||||
[access_scopes]
|
[access_scopes]
|
||||||
scopes = "read_orders,read_metaobjects"
|
scopes = "read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions"
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = [
|
redirect_urls = [
|
||||||
|
|
@ -105,6 +105,12 @@ shopify app config validate --config production --json
|
||||||
shopify app info --config production --json
|
shopify app info --config production --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
非交互环境中,如果还没有本地 link 状态,直接带目标 app Client ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app config validate --client-id <client-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
- `shopify app config validate --json` 返回 `valid: true`。
|
- `shopify app config validate --json` 返回 `valid: true`。
|
||||||
|
|
@ -119,12 +125,12 @@ shopify app info --config production --json
|
||||||
SHOPIFY_API_KEY=<client_id>
|
SHOPIFY_API_KEY=<client_id>
|
||||||
SHOPIFY_API_SECRET=<app_secret_from_dashboard>
|
SHOPIFY_API_SECRET=<app_secret_from_dashboard>
|
||||||
SHOPIFY_APP_URL=https://<public-backend-domain>
|
SHOPIFY_APP_URL=https://<public-backend-domain>
|
||||||
SCOPES=read_orders,read_metaobjects
|
SCOPES=read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions
|
||||||
DATABASE_URL=<database_connection_string>
|
DATABASE_URL=<database_connection_string>
|
||||||
THIRD_PARTY_MODE=live
|
THIRD_PARTY_MODE=live
|
||||||
THIRD_PARTY_TIMEOUT_MS=5000
|
THIRD_PARTY_TIMEOUT_MS=5000
|
||||||
LOG_RETENTION_DAYS=7
|
LOG_RETENTION_DAYS=7
|
||||||
METAOBJECT_TYPE=dreame_activity
|
METAOBJECT_TYPE=$app:dreame_activity
|
||||||
METAOBJECT_HANDLE=dreame-activity-config
|
METAOBJECT_HANDLE=dreame-activity-config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -153,11 +159,15 @@ DATABASE_URL="file:./dev.sqlite"
|
||||||
|
|
||||||
## 9. Metaobject 配置
|
## 9. Metaobject 配置
|
||||||
|
|
||||||
在目标店铺创建 Metaobject definition:
|
Metaobject definition 已声明在 `shopify.app.toml`,由 Shopify CLI 在 `shopify app dev/deploy` 时应用:
|
||||||
|
|
||||||
- Type: `dreame_activity`
|
- TOML key: `[metaobjects.app.dreame_activity]`
|
||||||
|
- Admin API type: `$app:dreame_activity`
|
||||||
- Handle: `dreame-activity-config`
|
- Handle: `dreame-activity-config`
|
||||||
- Storefront access: `NONE`
|
- Admin access: `merchant_read_write`
|
||||||
|
- Storefront access: `none`
|
||||||
|
|
||||||
|
如果目标店铺已经存在旧的 merchant-owned type `dreame_activity`,可以通过 `METAOBJECT_TYPE=dreame_activity` 兼容,但新环境建议使用 app-owned `$app:dreame_activity`。
|
||||||
|
|
||||||
字段:
|
字段:
|
||||||
|
|
||||||
|
|
@ -168,12 +178,33 @@ DATABASE_URL="file:./dev.sqlite"
|
||||||
| `api_secret` | `single_line_text_field` | yes |
|
| `api_secret` | `single_line_text_field` | yes |
|
||||||
| `status_api_url` | `url` | yes |
|
| `status_api_url` | `url` | yes |
|
||||||
| `push_api_url` | `url` | yes |
|
| `push_api_url` | `url` | yes |
|
||||||
|
| `status_success_code` | `number_integer` | no |
|
||||||
|
| `status_active_required` | `boolean` | no |
|
||||||
|
| `push_success_code` | `number_integer` | no |
|
||||||
| `redirect_url` | `url` | yes |
|
| `redirect_url` | `url` | yes |
|
||||||
| `button_text` | `json` | yes |
|
| `button_text` | `json` | yes |
|
||||||
| `shop` | `single_line_text_field` | no |
|
| `shop` | `single_line_text_field` | no |
|
||||||
|
|
||||||
`api_secret` 只能由后端读取,不应暴露给 Checkout UI Extension。
|
`api_secret` 只能由后端读取,不应暴露给 Checkout UI Extension。
|
||||||
|
|
||||||
|
可选字段默认值:
|
||||||
|
|
||||||
|
- `status_success_code`: `0`
|
||||||
|
- `status_active_required`: `true`
|
||||||
|
- `push_success_code`: `0`
|
||||||
|
|
||||||
|
若第三方确认接口②成功码不是 `0`,只需修改 Metaobject entry 的 `push_success_code`,不需要改代码。
|
||||||
|
|
||||||
|
写入或更新 entry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SHOPIFY_SHOP_DOMAIN=<shop>.myshopify.com \
|
||||||
|
SHOPIFY_ADMIN_ACCESS_TOKEN=shpat_xxx \
|
||||||
|
npx tsx scripts/setup-metaobject.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本使用 `metaobjectUpsert`,重复执行会更新同一个 handle,不会创建重复 entry。
|
||||||
|
|
||||||
## 10. 部署后端服务
|
## 10. 部署后端服务
|
||||||
|
|
||||||
Shopify CLI 的 `app deploy` 不会部署 Remix 后端服务。需要先把 web app 部署到自有托管环境,并确认:
|
Shopify CLI 的 `app deploy` 不会部署 Remix 后端服务。需要先把 web app 部署到自有托管环境,并确认:
|
||||||
|
|
@ -225,6 +256,16 @@ https://<public-backend-domain>
|
||||||
purchase.thank-you.block.render
|
purchase.thank-you.block.render
|
||||||
```
|
```
|
||||||
|
|
||||||
|
该 target 是 Thank You Page block target,商家可在 checkout and accounts editor 控制展示位置。若改为 `purchase.thank-you.header.render-after` 等静态 target,需要修改 `shopify.extension.toml` 并重新部署 app extension。
|
||||||
|
|
||||||
|
当前 TOML 设置:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
default_placement = "ORDER_STATUS1"
|
||||||
|
```
|
||||||
|
|
||||||
|
首次推荐位置为订单状态卡片上方;商家仍可在编辑器移动该 block。
|
||||||
|
|
||||||
## 13. 上线验收
|
## 13. 上线验收
|
||||||
|
|
||||||
测试订单验收:
|
测试订单验收:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,575 @@
|
||||||
|
# 追觅 Shopify App 最终方案与上线说明
|
||||||
|
|
||||||
|
> 本文件是当前代码目录的最终版方案和上线主口径。
|
||||||
|
> 历史材料包括 `README.md`、`docs/shopify-deployment-runbook.md` 以及 `初始化需求/` 下多份需求澄清文档;后续对业务、开发、测试和上线沟通时,以本文为准。
|
||||||
|
|
||||||
|
## 1. 当前结论
|
||||||
|
|
||||||
|
追觅 Shopify App 按 **Shopify Remix App + Checkout UI Extension + 后端同步触发** 实现。
|
||||||
|
|
||||||
|
当前版本可以进入开发/测试环境验证,但还不能直接生产上线。生产上线前必须完成真实 Shopify app link、真实店铺安装、生产数据库方案、第三方接口联调和真实测试订单验收。
|
||||||
|
|
||||||
|
锁定口径:
|
||||||
|
|
||||||
|
| 项目 | 最终口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| App 类型 | Custom app,当前按单店交付 |
|
||||||
|
| 页面范围 | v1 只做 Thank You Page |
|
||||||
|
| Extension target | `purchase.thank-you.block.render` |
|
||||||
|
| 默认展示位置 | `default_placement = "ORDER_STATUS1"`,商家仍可在 checkout and accounts editor 移动 |
|
||||||
|
| 前端职责 | 只取 `orderId/orderNumber/locale` 和 session token,调用后端 |
|
||||||
|
| 后端职责 | 校验 token、查订单、读配置、签名、调第三方、写日志和幂等 |
|
||||||
|
| 配置方式 | app-owned Metaobject `$app:dreame_activity` |
|
||||||
|
| 第三方成功码 | 通过 Metaobject 可配置,默认 `0` |
|
||||||
|
| 幂等 | `shop + orderId + activityId` 唯一,不重复推送 |
|
||||||
|
| 失败行为 | 返回 `success=false`,前端不显示按钮,不影响原生 Thank You Page |
|
||||||
|
|
||||||
|
## 2. 最终业务流程
|
||||||
|
|
||||||
|
1. 顾客完成支付后进入 Shopify Thank You Page。
|
||||||
|
2. Checkout UI Extension `purchase.thank-you.block.render` 加载。
|
||||||
|
3. 前端读取订单确认信息中的 `orderId/orderNumber/locale`。
|
||||||
|
4. 前端通过 `sessionToken.get()` 获取 Shopify checkout session token。
|
||||||
|
5. 前端调用后端 `POST /api/action/activity/check`。
|
||||||
|
6. 后端校验 session token,并通过 Admin GraphQL 查询完整订单。
|
||||||
|
7. 后端读取 `$app:dreame_activity` Metaobject entry。
|
||||||
|
8. 后端检查订单级幂等;已成功推送则直接返回按钮数据,不重复调用第三方。
|
||||||
|
9. 后端签名调用第三方接口①活动状态接口。
|
||||||
|
10. 活动有效后,后端生成 UUID v4 `event_id`,签名调用第三方接口②订单完成事件推送。
|
||||||
|
11. 推送成功后写日志和幂等记录。
|
||||||
|
12. 后端返回 `success=true + redirectUrl + buttonText`。
|
||||||
|
13. 前端显示按钮,点击后跳转到配置的 `redirect_url`。
|
||||||
|
|
||||||
|
## 3. 系统组件
|
||||||
|
|
||||||
|
| 组件 | 位置 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Shopify App 后端 | `app/` | Remix 后端、Shopify OAuth/session、业务 API、订单查询、配置读取、第三方调用 |
|
||||||
|
| Thank You Extension | `extensions/thank-you-activity/` | Thank You Page 按钮展示和后端调用 |
|
||||||
|
| 配置服务 | `app/services/config.server.ts` | 读取 `$app:dreame_activity` Metaobject |
|
||||||
|
| 主流程服务 | `app/services/activity.server.ts` | 状态检查、推送、幂等、返回按钮数据 |
|
||||||
|
| 第三方 client | `app/services/dreame-client.server.ts` | mock/live 第三方接口调用 |
|
||||||
|
| 签名服务 | `app/services/signature.server.ts` | `MD5(bodyString + timestamp + api_secret)` |
|
||||||
|
| 日志服务 | `app/services/logger.server.ts` | 写入调用日志并避免敏感信息外泄 |
|
||||||
|
| 幂等服务 | `app/services/idempotency.server.ts` | 防止同一订单重复推送 |
|
||||||
|
| Prisma 模型 | `prisma/schema.prisma` | `Session`、`ActivityPushLog`、`ActivityPushIdempotency` |
|
||||||
|
|
||||||
|
## 4. 后端接口契约
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/action/activity/check
|
||||||
|
```
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer <checkout session token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderId": "gid://shopify/Order/1234567890",
|
||||||
|
"orderNumber": "#1001",
|
||||||
|
"locale": "zh-CN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"redirectUrl": "https://activity.example.com",
|
||||||
|
"buttonText": "领取奖励"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 前端不传 `api_secret`。
|
||||||
|
- 前端传入的订单字段只作为最小定位信息,不作为可信业务数据。
|
||||||
|
- 完整订单数据由后端通过 Shopify Admin GraphQL 查询。
|
||||||
|
- CORS 对 Checkout Extension 放开,来源可信由 session token 校验负责。
|
||||||
|
|
||||||
|
## 5. Shopify 配置
|
||||||
|
|
||||||
|
### 5.1 App scopes
|
||||||
|
|
||||||
|
当前 scopes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
| Scope | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `read_orders` | 后端查询订单 email、customerId、currency、order number 等字段 |
|
||||||
|
| `read_metaobjects` | 后端读取活动配置 |
|
||||||
|
| `write_metaobjects` | setup 脚本写入/更新配置 entry |
|
||||||
|
| `write_metaobject_definitions` | Shopify CLI 部署 app-owned Metaobject definition |
|
||||||
|
|
||||||
|
### 5.2 Extension target
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
extensions/thank-you-activity/shopify.extension.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
当前配置:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/Checkout.tsx"
|
||||||
|
target = "purchase.thank-you.block.render"
|
||||||
|
default_placement = "ORDER_STATUS1"
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `purchase.thank-you.block.render` 是当前最终 target。
|
||||||
|
- `ORDER_STATUS1` 是默认推荐位置,不等于强制固定位置。
|
||||||
|
- 商家可在 Shopify checkout and accounts editor 中移动该 block。
|
||||||
|
- 若业务要求固定 header 位置,需要改 target、改代码并重新部署,不是业务配置项。
|
||||||
|
|
||||||
|
## 6. Metaobject 配置
|
||||||
|
|
||||||
|
当前采用 app-owned Metaobject:
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| TOML key | `[metaobjects.app.dreame_activity]` |
|
||||||
|
| Admin API type | `$app:dreame_activity` |
|
||||||
|
| Handle | `dreame-activity-config` |
|
||||||
|
| Admin access | `merchant_read_write` |
|
||||||
|
| Storefront access | `none` |
|
||||||
|
|
||||||
|
字段:
|
||||||
|
|
||||||
|
| Key | Type | Required | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `activity_id` | `number_integer` | yes | - | 追觅活动 ID |
|
||||||
|
| `api_app_id` | `single_line_text_field` | yes | - | 第三方接口 app id |
|
||||||
|
| `api_secret` | `single_line_text_field` | yes | - | 第三方签名 secret,仅后端读取 |
|
||||||
|
| `status_api_url` | `url` | yes | - | 接口①活动状态地址 |
|
||||||
|
| `push_api_url` | `url` | yes | - | 接口②推送地址 |
|
||||||
|
| `status_success_code` | `number_integer` | no | `0` | 接口①成功 code |
|
||||||
|
| `status_active_required` | `boolean` | no | `true` | 接口①是否必须 `data.status=true` |
|
||||||
|
| `push_success_code` | `number_integer` | no | `0` | 接口②成功 code |
|
||||||
|
| `redirect_url` | `url` | yes | - | 前端按钮跳转地址 |
|
||||||
|
| `button_text` | `json` | yes | - | 多语言按钮文案 |
|
||||||
|
| `shop` | `single_line_text_field` | no | myshopify domain | 覆盖传给第三方的 `properties.shop` |
|
||||||
|
|
||||||
|
`button_text` 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"en": "Claim Your Reward",
|
||||||
|
"zh-CN": "领取奖励",
|
||||||
|
"zh-TW": "領取獎勵",
|
||||||
|
"de": "Belohnung erhalten",
|
||||||
|
"fr": "Réclamer votre récompense"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容说明:
|
||||||
|
|
||||||
|
- 新环境默认使用 `$app:dreame_activity`。
|
||||||
|
- 若目标店铺已经存在旧的 merchant-owned type `dreame_activity`,可通过环境变量 `METAOBJECT_TYPE=dreame_activity` 兼容。
|
||||||
|
- 不建议新环境继续创建 merchant-owned `dreame_activity`。
|
||||||
|
|
||||||
|
## 7. 第三方接口与签名
|
||||||
|
|
||||||
|
所有第三方请求都由后端发起,前端不直接调用第三方。
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dreame-api-app-id: <api_app_id>
|
||||||
|
dreame-api-timestamp: <unix ms>
|
||||||
|
dreame-api-sign: <md5 sign>
|
||||||
|
```
|
||||||
|
|
||||||
|
签名算法:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sign = MD5(bodyString + timestamp + api_secret)
|
||||||
|
```
|
||||||
|
|
||||||
|
实现要求:
|
||||||
|
|
||||||
|
- `bodyString` 必须是实际发送的 JSON 字符串。
|
||||||
|
- 同一个 `bodyString` 同时用于签名和 `fetch` body。
|
||||||
|
- 禁止签名后再次序列化 payload。
|
||||||
|
- `api_secret` 不进入前端、不进入响应体、不进入日志。
|
||||||
|
|
||||||
|
接口①活动状态:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activity_id": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认继续条件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 2xx && code == status_success_code && data.status == true
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `status_active_required=false`,则不再要求 `data.status == true`。
|
||||||
|
|
||||||
|
接口②推送订单完成事件:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activity_id": 12,
|
||||||
|
"event_type": "order_end",
|
||||||
|
"created_at": 1776901800000,
|
||||||
|
"user_id": "99999",
|
||||||
|
"email": "customer@example.com",
|
||||||
|
"event_id": "uuid-v4",
|
||||||
|
"properties": {
|
||||||
|
"shop": "demo-store.myshopify.com",
|
||||||
|
"currency": "USD",
|
||||||
|
"order_id": "123456",
|
||||||
|
"order_number": "#1001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
默认成功条件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP 2xx && code == push_success_code
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 幂等和日志
|
||||||
|
|
||||||
|
幂等规则:
|
||||||
|
|
||||||
|
| 项目 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 幂等粒度 | `shop + orderId + activityId` |
|
||||||
|
| 命中后行为 | 不重复调用第三方接口②,仍返回 `success=true + redirectUrl + buttonText` |
|
||||||
|
| event_id | 首次成功推送时生成 UUID v4 并落库 |
|
||||||
|
|
||||||
|
数据库表:
|
||||||
|
|
||||||
|
| 表 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ActivityPushLog` | 记录成功/失败调用、请求体、响应体、错误信息 |
|
||||||
|
| `ActivityPushIdempotency` | 记录成功推送的订单和活动,防止重复推送 |
|
||||||
|
|
||||||
|
日志要求:
|
||||||
|
|
||||||
|
- 不保存 `api_secret`。
|
||||||
|
- 不保存 `dreame-api-sign`。
|
||||||
|
- 失败需要记录可排查的 `errorMessage`。
|
||||||
|
- 日志保留天数由 `LOG_RETENTION_DAYS` 控制,默认 `7`。
|
||||||
|
|
||||||
|
## 9. 本地开发说明
|
||||||
|
|
||||||
|
前置:
|
||||||
|
|
||||||
|
- Node.js 20 或更高版本。
|
||||||
|
- pnpm/corepack 可用。
|
||||||
|
- Shopify CLI 可用。
|
||||||
|
|
||||||
|
安装和验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/Users/jason/Downloads/myproject/Shopify 插件开发/dreame-shopify-app"
|
||||||
|
corepack pnpm install
|
||||||
|
corepack pnpm prisma:generate
|
||||||
|
corepack pnpm prisma:migrate
|
||||||
|
corepack pnpm test
|
||||||
|
corepack pnpm typecheck
|
||||||
|
corepack pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
本地环境变量:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SHOPIFY_API_KEY=
|
||||||
|
SHOPIFY_API_SECRET=
|
||||||
|
SHOPIFY_APP_URL=
|
||||||
|
SCOPES=read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions
|
||||||
|
DATABASE_URL="file:./dev.sqlite"
|
||||||
|
THIRD_PARTY_MODE=mock
|
||||||
|
THIRD_PARTY_TIMEOUT_MS=5000
|
||||||
|
LOG_RETENTION_DAYS=7
|
||||||
|
METAOBJECT_TYPE=$app:dreame_activity
|
||||||
|
METAOBJECT_HANDLE=dreame-activity-config
|
||||||
|
DREAME_MOCK_STATUS_CODE=0
|
||||||
|
DREAME_MOCK_STATUS_ACTIVE=true
|
||||||
|
DREAME_MOCK_PUSH_CODE=0
|
||||||
|
```
|
||||||
|
|
||||||
|
启动开发:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 默认 `THIRD_PARTY_MODE=mock`,不会真实调用第三方。
|
||||||
|
- 切换真实第三方接口前,必须先配置 Metaobject entry 和第三方密钥。
|
||||||
|
|
||||||
|
## 10. 上线前置条件
|
||||||
|
|
||||||
|
上线前必须准备:
|
||||||
|
|
||||||
|
| 类别 | 前置项 |
|
||||||
|
| --- | --- |
|
||||||
|
| Shopify | 真实测试/生产 Shopify app、Client ID、App secret |
|
||||||
|
| 店铺 | 目标店铺安装权限、测试订单能力、非 Starter 套餐 |
|
||||||
|
| 域名 | 后端公网 HTTPS 域名 |
|
||||||
|
| 数据库 | 生产数据库连接串和迁移窗口 |
|
||||||
|
| 第三方 | 接口①②正式/测试地址、`api_app_id`、`api_secret`、成功码规则、可验签样例 |
|
||||||
|
| 配置 | `$app:dreame_activity` definition 和 `dreame-activity-config` entry |
|
||||||
|
| Extension | Checkout UI Extension 已部署,`app_url` setting 指向后端域名 |
|
||||||
|
| 验收数据 | 成功订单、失败订单、活动关闭、重复刷新等测试场景 |
|
||||||
|
|
||||||
|
生产数据库特别说明:
|
||||||
|
|
||||||
|
- 当前 `prisma/schema.prisma` datasource provider 是 `sqlite`,适合本地开发。
|
||||||
|
- 生产建议使用 Postgres。
|
||||||
|
- SQLite 切 Postgres 不是只改 `DATABASE_URL`,需要改 Prisma datasource provider、生成 migration,并在目标数据库执行迁移。
|
||||||
|
- 未完成生产数据库迁移前,不建议生产上线。
|
||||||
|
|
||||||
|
## 11. 上线步骤
|
||||||
|
|
||||||
|
### 11.1 安装依赖和基础质量门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm install
|
||||||
|
corepack pnpm prisma:generate
|
||||||
|
corepack pnpm test
|
||||||
|
corepack pnpm typecheck
|
||||||
|
corepack pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
通过标准:
|
||||||
|
|
||||||
|
- 单测全部通过。
|
||||||
|
- TypeScript 无错误。
|
||||||
|
- Remix production build 成功。
|
||||||
|
|
||||||
|
### 11.2 链接 Shopify app
|
||||||
|
|
||||||
|
测试环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app config link --config staging --client-id <staging-client-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app config link --config production --client-id <production-client-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
非交互配置校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app config validate --client-id <client-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
已链接环境校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app config validate --config staging --json
|
||||||
|
shopify app config validate --config production --json
|
||||||
|
```
|
||||||
|
|
||||||
|
通过标准:
|
||||||
|
|
||||||
|
- validate 返回有效结果。
|
||||||
|
- app URL、redirect URLs、scopes 与目标环境一致。
|
||||||
|
- 不把 `.shopify/`、个人 `client_id`、个人 extension `uid` 当作通用配置提交。
|
||||||
|
|
||||||
|
### 11.3 配置后端环境变量
|
||||||
|
|
||||||
|
生产环境建议:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SHOPIFY_API_KEY=<production-client-id>
|
||||||
|
SHOPIFY_API_SECRET=<production-app-secret>
|
||||||
|
SHOPIFY_APP_URL=https://<production-backend-domain>
|
||||||
|
SCOPES=read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions
|
||||||
|
DATABASE_URL=<production-database-url>
|
||||||
|
THIRD_PARTY_MODE=live
|
||||||
|
THIRD_PARTY_TIMEOUT_MS=5000
|
||||||
|
LOG_RETENTION_DAYS=7
|
||||||
|
METAOBJECT_TYPE=$app:dreame_activity
|
||||||
|
METAOBJECT_HANDLE=dreame-activity-config
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止提交到 Git:
|
||||||
|
|
||||||
|
- `.env`
|
||||||
|
- `.env.staging`
|
||||||
|
- `.env.production`
|
||||||
|
- Admin access token
|
||||||
|
- Shopify app secret
|
||||||
|
- 第三方 `api_secret`
|
||||||
|
- 数据库密码
|
||||||
|
|
||||||
|
### 11.4 部署后端服务
|
||||||
|
|
||||||
|
后端服务部署到托管平台后,先检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i https://<backend-domain>/
|
||||||
|
curl -i -X OPTIONS https://<backend-domain>/api/action/activity/check
|
||||||
|
```
|
||||||
|
|
||||||
|
期望:
|
||||||
|
|
||||||
|
- 首页或健康页返回 200。
|
||||||
|
- OPTIONS 返回 204,并包含 CORS headers。
|
||||||
|
- 未带 session token 的 POST 不应成功推送。
|
||||||
|
|
||||||
|
### 11.5 部署 Shopify 配置和 Extension
|
||||||
|
|
||||||
|
测试环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app deploy --config staging --allow-updates --message "staging deployment"
|
||||||
|
```
|
||||||
|
|
||||||
|
生产候选:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app deploy --config production --no-release --message "production candidate"
|
||||||
|
```
|
||||||
|
|
||||||
|
生产发布:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shopify app deploy --config production --allow-updates --message "production release"
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境不要使用 `--allow-deletes`,除非已经明确确认要删除远端扩展或配置。
|
||||||
|
|
||||||
|
### 11.6 创建或更新 Metaobject entry
|
||||||
|
|
||||||
|
脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SHOPIFY_SHOP_DOMAIN=<shop>.myshopify.com \
|
||||||
|
SHOPIFY_ADMIN_ACCESS_TOKEN=shpat_xxx \
|
||||||
|
corepack pnpm tsx scripts/setup-metaobject.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 脚本使用 `metaobjectUpsert`。
|
||||||
|
- 重复执行会更新同一个 handle,不会创建重复 entry。
|
||||||
|
- 脚本里的默认值是示例值,上线前必须替换为真实业务配置。
|
||||||
|
|
||||||
|
### 11.7 配置 Extension setting
|
||||||
|
|
||||||
|
在 Shopify app 的 Checkout UI Extension 设置中配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app_url = https://<backend-domain>
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 checkout and accounts editor 中确认 block 已添加到 Thank You Page。
|
||||||
|
|
||||||
|
## 12. 上线验收
|
||||||
|
|
||||||
|
基础验收:
|
||||||
|
|
||||||
|
| 验收项 | 通过标准 |
|
||||||
|
| --- | --- |
|
||||||
|
| Extension 加载 | Thank You Page 中可以看到扩展区域 |
|
||||||
|
| 后端调用 | `/api/action/activity/check` 被调用 |
|
||||||
|
| Token 校验 | 未授权请求不能触发成功推送 |
|
||||||
|
| Metaobject 读取 | 后端读取到 `$app:dreame_activity` 配置 |
|
||||||
|
| 接口① | 签名正确,活动开启时继续 |
|
||||||
|
| 接口② | 只在接口①满足条件后调用 |
|
||||||
|
| 按钮展示 | 成功时显示本地化按钮 |
|
||||||
|
| 跳转 | 点击按钮跳转到 `redirect_url` |
|
||||||
|
| 幂等 | 同一订单重复刷新不重复推送 |
|
||||||
|
| 日志 | 成功/失败均有日志,不含密钥和签名 |
|
||||||
|
|
||||||
|
测试场景:
|
||||||
|
|
||||||
|
1. 成功订单:活动开启、接口②成功,页面显示按钮。
|
||||||
|
2. 活动关闭:接口①返回 inactive,页面不显示按钮。
|
||||||
|
3. 接口②失败:页面不显示按钮,日志记录失败。
|
||||||
|
4. 缺 email/customerId:不推送,返回 `success=false`。
|
||||||
|
5. 重复刷新 Thank You Page:不重复调用接口②,仍显示按钮。
|
||||||
|
6. 多语言:`zh-CN` 命中文案,未知 locale 回退 `en`。
|
||||||
|
7. 超时:第三方超时不阻塞 Shopify 原生页面。
|
||||||
|
|
||||||
|
## 13. 回滚方案
|
||||||
|
|
||||||
|
| 问题 | 回滚方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 后端发布异常 | 回滚托管平台 web app release |
|
||||||
|
| Shopify Extension 异常 | 在 Shopify Developer Dashboard 回滚到上一 app version |
|
||||||
|
| Metaobject 配置错误 | 修正 `dreame-activity-config` entry 后重新验证 |
|
||||||
|
| 第三方接口异常 | 测试环境可临时切回 `THIRD_PARTY_MODE=mock`;生产不建议长期 mock |
|
||||||
|
| 数据库迁移异常 | 停止发布,恢复数据库备份或回滚 migration |
|
||||||
|
|
||||||
|
## 14. 仍需确认后再迭代的事项
|
||||||
|
|
||||||
|
这些事项不能只靠业务配置解决,需要确认后改代码、改部署或单独排期:
|
||||||
|
|
||||||
|
| 事项 | 当前默认 | 后续动作 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 是否支持 Order Status Page | v1 不做 | 若要支持,单独设计 Customer Account/Order Status 实现 |
|
||||||
|
| 是否改成 header 固定位置 | 不改,继续 block target | 若要固定顶部,改 extension target 并重新部署 |
|
||||||
|
| 按钮是否必须新窗口打开 | 当前使用现有 Button `to` 行为 | 若必须 `_blank`,评估 UI extension 组件升级 |
|
||||||
|
| 生产数据库 | 建议 Postgres | 上线前确认 provider、连接串、migration |
|
||||||
|
| 是否增加 webhook 兜底 | v1 不做 | 若要求“未进入 Thank You Page 也推送”,二期加 `orders/paid` webhook |
|
||||||
|
| 接口②是否增加 `lineItems/totalPrice` | 当前不传 | 第三方确认字段格式后再改 payload |
|
||||||
|
| `trigger` 无 `order_end` 时是否推送 | 当前主线按 `order_end` | 若第三方强依赖 trigger,需明确规则 |
|
||||||
|
| 是否做后台配置页 | v1 不做 | 二期用 Polaris App Home 管理配置 |
|
||||||
|
|
||||||
|
## 15. 最终检查清单
|
||||||
|
|
||||||
|
上线前逐项勾选:
|
||||||
|
|
||||||
|
- [ ] `corepack pnpm test` 通过。
|
||||||
|
- [ ] `corepack pnpm typecheck` 通过。
|
||||||
|
- [ ] `corepack pnpm build` 通过。
|
||||||
|
- [ ] Shopify app 已 link 到正确 staging/production app。
|
||||||
|
- [ ] `shopify app config validate --json` 通过。
|
||||||
|
- [ ] 后端 HTTPS 域名可访问。
|
||||||
|
- [ ] Shopify app URL 和 redirect URLs 已改成目标域名。
|
||||||
|
- [ ] 生产数据库方案和 migration 已完成。
|
||||||
|
- [ ] `$app:dreame_activity` definition 已部署。
|
||||||
|
- [ ] `dreame-activity-config` entry 已填真实配置。
|
||||||
|
- [ ] Extension `app_url` setting 指向目标后端域名。
|
||||||
|
- [ ] 已使用真实测试订单完成成功/失败/重复刷新验证。
|
||||||
|
- [ ] 日志不含 `api_secret`、`dreame-api-sign`、Admin token。
|
||||||
|
- [ ] Git 中没有 `.env`、`.shopify/`、个人 app 绑定文件或密钥。
|
||||||
|
|
||||||
|
## 16. 参考资料
|
||||||
|
|
||||||
|
- Shopify Thank You block target: https://shopify.dev/docs/api/checkout-ui-extensions/latest/targets/thank-you/block
|
||||||
|
- Shopify Checkout UI Extension targets: https://shopify.dev/docs/api/checkout-ui-extensions/latest/targets
|
||||||
|
- Shopify Metaobject definitions: https://shopify.dev/docs/apps/build/metaobjects/manage-metaobject-definitions
|
||||||
|
- Shopify `metaobjectUpsert`: https://shopify.dev/docs/api/admin-graphql/latest/mutations/metaobjectUpsert
|
||||||
|
- Shopify CLI: https://shopify.dev/docs/api/shopify-cli
|
||||||
|
|
@ -8,6 +8,7 @@ type = "ui_extension"
|
||||||
[[extensions.targeting]]
|
[[extensions.targeting]]
|
||||||
module = "./src/Checkout.tsx"
|
module = "./src/Checkout.tsx"
|
||||||
target = "purchase.thank-you.block.render"
|
target = "purchase.thank-you.block.render"
|
||||||
|
default_placement = "ORDER_STATUS1"
|
||||||
|
|
||||||
[extensions.capabilities]
|
[extensions.capabilities]
|
||||||
network_access = true
|
network_access = true
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,8 @@
|
||||||
|
packages:
|
||||||
|
- "."
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
"@prisma/client": true
|
||||||
|
"@prisma/engines": true
|
||||||
|
esbuild: true
|
||||||
|
prisma: true
|
||||||
|
|
@ -3,28 +3,20 @@ type GraphqlResponse<T> = {
|
||||||
errors?: unknown;
|
errors?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UserError = {
|
||||||
|
field?: string[];
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
const shop = requiredEnv("SHOPIFY_SHOP_DOMAIN");
|
const shop = requiredEnv("SHOPIFY_SHOP_DOMAIN");
|
||||||
const token = requiredEnv("SHOPIFY_ADMIN_ACCESS_TOKEN");
|
const token = requiredEnv("SHOPIFY_ADMIN_ACCESS_TOKEN");
|
||||||
|
const metaobjectType = process.env.METAOBJECT_TYPE || "$app:dreame_activity";
|
||||||
|
const metaobjectHandle = process.env.METAOBJECT_HANDLE || "dreame-activity-config";
|
||||||
const endpoint = `https://${shop}/admin/api/2026-01/graphql.json`;
|
const endpoint = `https://${shop}/admin/api/2026-01/graphql.json`;
|
||||||
|
|
||||||
const definitionMutation = `#graphql
|
|
||||||
mutation DreameMetaobjectDefinitionCreate($definition: MetaobjectDefinitionCreateInput!) {
|
|
||||||
metaobjectDefinitionCreate(definition: $definition) {
|
|
||||||
metaobjectDefinition {
|
|
||||||
id
|
|
||||||
type
|
|
||||||
}
|
|
||||||
userErrors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const metaobjectMutation = `#graphql
|
const metaobjectMutation = `#graphql
|
||||||
mutation DreameMetaobjectCreate($metaobject: MetaobjectCreateInput!) {
|
mutation DreameMetaobjectUpsert($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
|
||||||
metaobjectCreate(metaobject: $metaobject) {
|
metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
|
||||||
metaobject {
|
metaobject {
|
||||||
id
|
id
|
||||||
handle
|
handle
|
||||||
|
|
@ -38,40 +30,36 @@ const metaobjectMutation = `#graphql
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await graphql(definitionMutation, {
|
const result = await graphql<{
|
||||||
definition: {
|
metaobjectUpsert?: {
|
||||||
type: "dreame_activity",
|
userErrors: UserError[];
|
||||||
name: "Dreame Activity",
|
};
|
||||||
access: { storefront: "NONE" },
|
}>(metaobjectMutation, {
|
||||||
fieldDefinitions: [
|
handle: {
|
||||||
{ key: "activity_id", name: "Activity ID", type: "number_integer", required: true },
|
type: metaobjectType,
|
||||||
{ key: "api_app_id", name: "API App ID", type: "single_line_text_field", required: true },
|
handle: metaobjectHandle,
|
||||||
{ key: "api_secret", name: "API Secret", type: "single_line_text_field", required: true },
|
|
||||||
{ key: "status_api_url", name: "Status API URL", type: "url", required: true },
|
|
||||||
{ key: "push_api_url", name: "Push API URL", type: "url", required: true },
|
|
||||||
{ key: "redirect_url", name: "Redirect URL", type: "url", required: true },
|
|
||||||
{ key: "button_text", name: "Button Text", type: "json", required: true },
|
|
||||||
{ key: "shop", name: "Shop Override", type: "single_line_text_field", required: false },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
await graphql(metaobjectMutation, {
|
|
||||||
metaobject: {
|
metaobject: {
|
||||||
type: "dreame_activity",
|
|
||||||
handle: "dreame-activity-config",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "activity_id", value: "12" },
|
{ key: "activity_id", value: "12" },
|
||||||
{ key: "api_app_id", value: "mock-app-id" },
|
{ key: "api_app_id", value: "mock-app-id" },
|
||||||
{ key: "api_secret", value: "mock-secret" },
|
{ key: "api_secret", value: "mock-secret" },
|
||||||
{ key: "status_api_url", value: "https://third-party.example/status" },
|
{ key: "status_api_url", value: "https://third-party.example/status" },
|
||||||
{ key: "push_api_url", value: "https://third-party.example/push" },
|
{ key: "push_api_url", value: "https://third-party.example/push" },
|
||||||
|
{ key: "status_success_code", value: "0" },
|
||||||
|
{ key: "status_active_required", value: "true" },
|
||||||
|
{ key: "push_success_code", value: "0" },
|
||||||
{ key: "redirect_url", value: "https://activity.example.com" },
|
{ key: "redirect_url", value: "https://activity.example.com" },
|
||||||
{ key: "button_text", value: JSON.stringify({ en: "Claim Your Reward", "zh-CN": "领取奖励" }) },
|
{ key: "button_text", value: JSON.stringify({ en: "Claim Your Reward", "zh-CN": "领取奖励" }) },
|
||||||
{ key: "shop", value: shop },
|
{ key: "shop", value: shop },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userErrors = result.data?.metaobjectUpsert?.userErrors || [];
|
||||||
|
if (userErrors.length) {
|
||||||
|
throw new Error(JSON.stringify(userErrors));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function graphql<T>(query: string, variables: Record<string, unknown>): Promise<GraphqlResponse<T>> {
|
async function graphql<T>(query: string, variables: Record<string, unknown>): Promise<GraphqlResponse<T>> {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ automatically_update_urls_on_dev = true
|
||||||
include_config_on_deploy = true
|
include_config_on_deploy = true
|
||||||
|
|
||||||
[access_scopes]
|
[access_scopes]
|
||||||
scopes = "read_orders,read_metaobjects"
|
scopes = "read_orders,read_metaobjects,write_metaobjects,write_metaobject_definitions"
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = [
|
redirect_urls = [
|
||||||
|
|
@ -16,3 +16,63 @@ redirect_urls = [
|
||||||
"https://example.ngrok-free.app/auth/shopify/callback",
|
"https://example.ngrok-free.app/auth/shopify/callback",
|
||||||
"https://example.ngrok-free.app/api/auth/callback"
|
"https://example.ngrok-free.app/api/auth/callback"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity]
|
||||||
|
name = "Dreame Activity"
|
||||||
|
access.admin = "merchant_read_write"
|
||||||
|
access.storefront = "none"
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.activity_id]
|
||||||
|
name = "Activity ID"
|
||||||
|
type = "number_integer"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.api_app_id]
|
||||||
|
name = "API App ID"
|
||||||
|
type = "single_line_text_field"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.api_secret]
|
||||||
|
name = "API Secret"
|
||||||
|
type = "single_line_text_field"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.status_api_url]
|
||||||
|
name = "Status API URL"
|
||||||
|
type = "url"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.push_api_url]
|
||||||
|
name = "Push API URL"
|
||||||
|
type = "url"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.status_success_code]
|
||||||
|
name = "Status Success Code"
|
||||||
|
type = "number_integer"
|
||||||
|
required = false
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.status_active_required]
|
||||||
|
name = "Status Active Required"
|
||||||
|
type = "boolean"
|
||||||
|
required = false
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.push_success_code]
|
||||||
|
name = "Push Success Code"
|
||||||
|
type = "number_integer"
|
||||||
|
required = false
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.redirect_url]
|
||||||
|
name = "Redirect URL"
|
||||||
|
type = "url"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.button_text]
|
||||||
|
name = "Button Text"
|
||||||
|
type = "json"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[metaobjects.app.dreame_activity.fields.shop]
|
||||||
|
name = "Shop Override"
|
||||||
|
type = "single_line_text_field"
|
||||||
|
required = false
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,69 @@ describe("activity flow", () => {
|
||||||
expect(result).toEqual({ success: false });
|
expect(result).toEqual({ success: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses configured push success code", async () => {
|
||||||
|
const result = await runActivityCheck(
|
||||||
|
{ shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" },
|
||||||
|
baseDeps({
|
||||||
|
getConfig: async () => ({
|
||||||
|
activityId: 12,
|
||||||
|
apiAppId: "app-id",
|
||||||
|
apiSecret: "secret",
|
||||||
|
statusApiUrl: "https://third.example/status",
|
||||||
|
pushApiUrl: "https://third.example/push",
|
||||||
|
redirectUrl: "https://activity.example.com",
|
||||||
|
buttonText: { en: "Claim Your Reward", "zh-CN": "领取奖励" },
|
||||||
|
pushSuccessCode: 2001,
|
||||||
|
}),
|
||||||
|
callPush: async () => ({
|
||||||
|
httpOk: true,
|
||||||
|
status: 200,
|
||||||
|
body: { code: 2001 },
|
||||||
|
requestBody: "{}",
|
||||||
|
responseBody: '{"code":2001}',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
redirectUrl: "https://activity.example.com",
|
||||||
|
buttonText: "Claim Your Reward",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses configured status success code and active flag policy", async () => {
|
||||||
|
const result = await runActivityCheck(
|
||||||
|
{ shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" },
|
||||||
|
baseDeps({
|
||||||
|
getConfig: async () => ({
|
||||||
|
activityId: 12,
|
||||||
|
apiAppId: "app-id",
|
||||||
|
apiSecret: "secret",
|
||||||
|
statusApiUrl: "https://third.example/status",
|
||||||
|
pushApiUrl: "https://third.example/push",
|
||||||
|
redirectUrl: "https://activity.example.com",
|
||||||
|
buttonText: { en: "Claim Your Reward", "zh-CN": "领取奖励" },
|
||||||
|
statusSuccessCode: 2001,
|
||||||
|
statusActiveRequired: false,
|
||||||
|
}),
|
||||||
|
callStatus: async () => ({
|
||||||
|
httpOk: true,
|
||||||
|
status: 200,
|
||||||
|
body: { code: 2001 },
|
||||||
|
requestBody: '{"activity_id":12}',
|
||||||
|
responseBody: '{"code":2001}',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
redirectUrl: "https://activity.example.com",
|
||||||
|
buttonText: "Claim Your Reward",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("does not push when email is missing", async () => {
|
test("does not push when email is missing", async () => {
|
||||||
let pushCalls = 0;
|
let pushCalls = 0;
|
||||||
const result = await runActivityCheck(
|
const result = await runActivityCheck(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { getActivityConfig } from "../app/services/config.server";
|
||||||
|
|
||||||
|
const originalType = process.env.METAOBJECT_TYPE;
|
||||||
|
const originalHandle = process.env.METAOBJECT_HANDLE;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.METAOBJECT_TYPE = originalType;
|
||||||
|
process.env.METAOBJECT_HANDLE = originalHandle;
|
||||||
|
});
|
||||||
|
|
||||||
|
function metaobjectResponse(fields: Array<{ key: string; value: string }>) {
|
||||||
|
return {
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
metaobjectByHandle: {
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFields() {
|
||||||
|
return [
|
||||||
|
{ key: "activity_id", value: "12" },
|
||||||
|
{ key: "api_app_id", value: "app-id" },
|
||||||
|
{ key: "api_secret", value: "secret" },
|
||||||
|
{ key: "status_api_url", value: "https://third.example/status" },
|
||||||
|
{ key: "push_api_url", value: "https://third.example/push" },
|
||||||
|
{ key: "status_success_code", value: "2001" },
|
||||||
|
{ key: "status_active_required", value: "false" },
|
||||||
|
{ key: "push_success_code", value: "2002" },
|
||||||
|
{ key: "redirect_url", value: "https://activity.example.com" },
|
||||||
|
{ key: "button_text", value: JSON.stringify({ en: "Claim Your Reward" }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("activity config", () => {
|
||||||
|
test("loads app-owned metaobject type by default and parses optional success criteria", async () => {
|
||||||
|
delete process.env.METAOBJECT_TYPE;
|
||||||
|
delete process.env.METAOBJECT_HANDLE;
|
||||||
|
|
||||||
|
let variables: Record<string, unknown> | undefined;
|
||||||
|
const config = await getActivityConfig({
|
||||||
|
graphql: async (_query, options) => {
|
||||||
|
variables = options?.variables;
|
||||||
|
return metaobjectResponse(configFields());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(variables).toEqual({ type: "$app:dreame_activity", handle: "dreame-activity-config" });
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
activityId: 12,
|
||||||
|
statusSuccessCode: 2001,
|
||||||
|
statusActiveRequired: false,
|
||||||
|
pushSuccessCode: 2002,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows overriding the metaobject type for merchant-owned legacy configs", async () => {
|
||||||
|
process.env.METAOBJECT_TYPE = "dreame_activity";
|
||||||
|
process.env.METAOBJECT_HANDLE = "legacy-config";
|
||||||
|
|
||||||
|
let variables: Record<string, unknown> | undefined;
|
||||||
|
await getActivityConfig({
|
||||||
|
graphql: async (_query, options) => {
|
||||||
|
variables = options?.variables;
|
||||||
|
return metaobjectResponse(configFields());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(variables).toEqual({ type: "dreame_activity", handle: "legacy-config" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue