293 lines
8.0 KiB
Markdown
293 lines
8.0 KiB
Markdown
|
|
# Dreame Shopify App
|
||
|
|
|
||
|
|
Shopify custom app scaffold for the Dreame Thank You Page activity flow.
|
||
|
|
|
||
|
|
This project follows:
|
||
|
|
|
||
|
|
- `/初始化需求/追觅Shopify App Codex编码方案指令.md`
|
||
|
|
- Scope: local runnable code and deployment documentation only.
|
||
|
|
- Out of scope: real deployment, production database provisioning, real third-party API integration, real test orders.
|
||
|
|
|
||
|
|
## What This App Does
|
||
|
|
|
||
|
|
1. Customer lands on Shopify Thank You Page.
|
||
|
|
2. Checkout UI Extension target `purchase.thank-you.block.render` loads.
|
||
|
|
3. Extension gets `orderId`, `orderNumber`, `locale`, and a Shopify session token.
|
||
|
|
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.
|
||
|
|
6. On success, backend returns `success`, `redirectUrl`, and localized `buttonText`.
|
||
|
|
7. Extension renders a button and opens `redirectUrl` in the current window.
|
||
|
|
|
||
|
|
Failures return `{ "success": false }`; the extension renders nothing and does not block the native Thank You Page.
|
||
|
|
|
||
|
|
## Local Setup
|
||
|
|
|
||
|
|
The Shopify CLI was not available in this workspace, and remote `npm create @shopify/app@latest` execution was blocked by local safety policy. The project has therefore been initialized as a local scaffold matching the requested Shopify Remix structure.
|
||
|
|
|
||
|
|
When network access is available:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd "/Users/jason/Downloads/myproject/Shopify 插件开发/dreame-shopify-app"
|
||
|
|
npm install
|
||
|
|
cp .env.example .env
|
||
|
|
npx prisma generate
|
||
|
|
npx prisma migrate dev
|
||
|
|
shopify app dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Install the app to the development shop:
|
||
|
|
|
||
|
|
```text
|
||
|
|
function-613wh0ez.myshopify.com
|
||
|
|
```
|
||
|
|
|
||
|
|
## Environment
|
||
|
|
|
||
|
|
Copy `.env.example` to `.env` and fill Shopify credentials:
|
||
|
|
|
||
|
|
```env
|
||
|
|
SHOPIFY_API_KEY=
|
||
|
|
SHOPIFY_API_SECRET=
|
||
|
|
SHOPIFY_APP_URL=
|
||
|
|
SCOPES=read_orders,read_metaobjects
|
||
|
|
DATABASE_URL="file:./dev.sqlite"
|
||
|
|
THIRD_PARTY_MODE=mock
|
||
|
|
THIRD_PARTY_TIMEOUT_MS=5000
|
||
|
|
LOG_RETENTION_DAYS=7
|
||
|
|
METAOBJECT_TYPE=dreame_activity
|
||
|
|
METAOBJECT_HANDLE=dreame-activity-config
|
||
|
|
```
|
||
|
|
|
||
|
|
`THIRD_PARTY_MODE=mock` is the default because third-party APIs, signing samples, and test orders were not provided.
|
||
|
|
|
||
|
|
## Metaobject Configuration
|
||
|
|
|
||
|
|
Create one Metaobject definition:
|
||
|
|
|
||
|
|
- Type: `dreame_activity`
|
||
|
|
- Suggested handle: `dreame-activity-config`
|
||
|
|
- Storefront access: `NONE`
|
||
|
|
|
||
|
|
Fields:
|
||
|
|
|
||
|
|
| Key | Type | Required | Notes |
|
||
|
|
| --- | --- | --- | --- |
|
||
|
|
| `activity_id` | `number_integer` | yes | Dreame activity ID |
|
||
|
|
| `api_app_id` | `single_line_text_field` | yes | Sent as `dreame-api-app-id` |
|
||
|
|
| `api_secret` | `single_line_text_field` | yes | Backend only; never expose to extension |
|
||
|
|
| `status_api_url` | `url` | yes | Activity status API |
|
||
|
|
| `push_api_url` | `url` | yes | Activity push API |
|
||
|
|
| `redirect_url` | `url` | yes | Button target URL |
|
||
|
|
| `button_text` | `json` | yes | Locale map |
|
||
|
|
| `shop` | `single_line_text_field` | no | Overrides `properties.shop`; fallback is myshopify domain |
|
||
|
|
|
||
|
|
Example `button_text`:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"en": "Claim Your Reward",
|
||
|
|
"zh-CN": "领取奖励",
|
||
|
|
"zh-TW": "領取獎勵",
|
||
|
|
"de": "Belohnung erhalten",
|
||
|
|
"fr": "Réclamer votre récompense"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Optional setup script:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
SHOPIFY_SHOP_DOMAIN=function-613wh0ez.myshopify.com \
|
||
|
|
SHOPIFY_ADMIN_ACCESS_TOKEN=shpat_xxx \
|
||
|
|
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.
|
||
|
|
|
||
|
|
## API Contract
|
||
|
|
|
||
|
|
Endpoint:
|
||
|
|
|
||
|
|
```text
|
||
|
|
POST /api/action/activity/check
|
||
|
|
```
|
||
|
|
|
||
|
|
Headers:
|
||
|
|
|
||
|
|
```text
|
||
|
|
Authorization: Bearer <checkout session token>
|
||
|
|
Content-Type: application/json
|
||
|
|
```
|
||
|
|
|
||
|
|
Request:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"orderId": "gid://shopify/Order/1234567890",
|
||
|
|
"orderNumber": "#1001",
|
||
|
|
"locale": "zh-CN"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Success response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"success": true,
|
||
|
|
"redirectUrl": "https://activity.example.com",
|
||
|
|
"buttonText": "领取奖励"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Failure response:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"success": false
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
CORS headers are returned for `POST` and `OPTIONS`; source trust relies on Shopify checkout session token validation.
|
||
|
|
|
||
|
|
## Dreame Signing
|
||
|
|
|
||
|
|
All third-party requests include:
|
||
|
|
|
||
|
|
- `dreame-api-app-id`
|
||
|
|
- `dreame-api-timestamp`
|
||
|
|
- `dreame-api-sign`
|
||
|
|
|
||
|
|
Signature:
|
||
|
|
|
||
|
|
```text
|
||
|
|
sign = MD5(bodyString + timestamp + api_secret)
|
||
|
|
```
|
||
|
|
|
||
|
|
The implementation serializes the payload once with `JSON.stringify(payload)`. The exact same `bodyString` is used both for signing and as the `fetch` body.
|
||
|
|
|
||
|
|
## Mock Mode
|
||
|
|
|
||
|
|
Default mock behavior:
|
||
|
|
|
||
|
|
- Status API returns `code=0` and `data.status=true`.
|
||
|
|
- Push API returns `code=0`.
|
||
|
|
|
||
|
|
Useful mock environment variables:
|
||
|
|
|
||
|
|
```env
|
||
|
|
DREAME_MOCK_STATUS_CODE=0
|
||
|
|
DREAME_MOCK_STATUS_ACTIVE=true
|
||
|
|
DREAME_MOCK_PUSH_CODE=0
|
||
|
|
DREAME_MOCK_TIMEOUT=false
|
||
|
|
```
|
||
|
|
|
||
|
|
Switch to live calls only after third-party endpoints and credentials are available:
|
||
|
|
|
||
|
|
```env
|
||
|
|
THIRD_PARTY_MODE=live
|
||
|
|
```
|
||
|
|
|
||
|
|
## Database
|
||
|
|
|
||
|
|
Development uses SQLite:
|
||
|
|
|
||
|
|
```env
|
||
|
|
DATABASE_URL="file:./dev.sqlite"
|
||
|
|
```
|
||
|
|
|
||
|
|
Production should use Postgres. The same Prisma schema applies after changing the datasource provider and connection string as part of production hardening.
|
||
|
|
|
||
|
|
Tables added by this scaffold:
|
||
|
|
|
||
|
|
- `ActivityPushLog`: one row per status/push attempt or failure.
|
||
|
|
- `ActivityPushIdempotency`: unique key on `shop + orderId + activityId`; prevents duplicate push for the same order and activity.
|
||
|
|
|
||
|
|
Logs are sanitized before persistence and must not store `api_secret` or `dreame-api-sign`.
|
||
|
|
|
||
|
|
## Checkout UI Extension
|
||
|
|
|
||
|
|
Location:
|
||
|
|
|
||
|
|
```text
|
||
|
|
extensions/thank-you-activity
|
||
|
|
```
|
||
|
|
|
||
|
|
Target:
|
||
|
|
|
||
|
|
```text
|
||
|
|
purchase.thank-you.block.render
|
||
|
|
```
|
||
|
|
|
||
|
|
Capabilities:
|
||
|
|
|
||
|
|
```toml
|
||
|
|
[extensions.capabilities]
|
||
|
|
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.
|
||
|
|
|
||
|
|
## Tests
|
||
|
|
|
||
|
|
After dependencies are installed:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm test
|
||
|
|
npm run typecheck
|
||
|
|
```
|
||
|
|
|
||
|
|
Current test coverage:
|
||
|
|
|
||
|
|
- `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/activity-flow.test.ts`: inactive status, non-zero status code, successful push payload shape, push failure, missing email, missing customer ID.
|
||
|
|
|
||
|
|
## Deployment Steps
|
||
|
|
|
||
|
|
This task does not deploy. For production deployment later:
|
||
|
|
|
||
|
|
1. Provision Postgres.
|
||
|
|
2. Set production environment variables.
|
||
|
|
3. Update `shopify.app.toml` URLs.
|
||
|
|
4. Run Prisma migration against production.
|
||
|
|
5. Run `shopify app deploy`.
|
||
|
|
6. Publish the Checkout UI Extension.
|
||
|
|
7. Configure the extension `app_url` setting.
|
||
|
|
8. Create the `dreame_activity` Metaobject in the production shop.
|
||
|
|
9. Switch `THIRD_PARTY_MODE=live`.
|
||
|
|
10. Run end-to-end validation with real test orders and third-party test endpoints.
|
||
|
|
|
||
|
|
## Security Notes
|
||
|
|
|
||
|
|
- `api_secret` is backend-only.
|
||
|
|
- The extension never reads Metaobject fields directly.
|
||
|
|
- Third-party calls are always made by the backend.
|
||
|
|
- Frontend-provided order data is not trusted; backend queries Shopify Admin GraphQL.
|
||
|
|
- Logs are sanitized and pruned after `LOG_RETENTION_DAYS` days.
|
||
|
|
|
||
|
|
## Acceptance Coverage
|
||
|
|
|
||
|
|
| Requirement | Coverage |
|
||
|
|
| --- | --- |
|
||
|
|
| Thank You Page loads extension | `extensions/thank-you-activity` |
|
||
|
|
| Page automatically calls backend | `Checkout.tsx` effect |
|
||
|
|
| Backend reads configuration | `config.server.ts` |
|
||
|
|
| Status API called | `dreame-client.server.ts`, `activity.server.ts` |
|
||
|
|
| `status=true` calls push API | `activity.server.ts`, tests |
|
||
|
|
| Push success returns `success=true` | `activity-flow.test.ts` |
|
||
|
|
| Button displays | `Checkout.tsx` |
|
||
|
|
| Button supports localization | `locale.ts`, `activity.server.ts` |
|
||
|
|
| Button redirects to configured URL | `Checkout.tsx` |
|
||
|
|
| Same order is not pushed twice | `idempotency.server.ts`, `idempotency.test.ts` |
|
||
|
|
| Calls are logged | `logger.server.ts` |
|
||
|
|
| Third-party signing is correct | `signature.server.ts`, `signature.test.ts` |
|
||
|
|
| Sensitive data is not exposed to frontend | backend-only config and sanitized logs |
|
||
|
|
|
||
|
|
## Out of Scope For This Phase
|
||
|
|
|
||
|
|
- Order Status Page.
|
||
|
|
- `orders/paid` webhook fallback.
|
||
|
|
- App admin UI for managing Metaobjects.
|
||
|
|
- Actual deployment.
|
||
|
|
- Real third-party API validation.
|
||
|
|
- Real Shopify test order validation.
|