chore: initialize dreame shopify app

This commit is contained in:
lzs 2026-06-19 00:53:13 +08:00
commit 807ac75be9
28 changed files with 1909 additions and 0 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
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
DREAME_MOCK_STATUS_CODE=0
DREAME_MOCK_STATUS_ACTIVE=true
DREAME_MOCK_PUSH_CODE=0

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
.env
.env.*
!.env.example
build
.shopify
.turbo
coverage
prisma/dev.sqlite
prisma/dev.sqlite-journal
extensions/**/dist
.DS_Store

292
README.md Normal file
View File

@ -0,0 +1,292 @@
# 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.

14
app/db.server.ts Normal file
View File

@ -0,0 +1,14 @@
import { PrismaClient } from "@prisma/client";
declare global {
// eslint-disable-next-line no-var
var __dreamePrisma: PrismaClient | undefined;
}
const db = global.__dreamePrisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
global.__dreamePrisma = db;
}
export default db;

26
app/lib/locale.ts Normal file
View File

@ -0,0 +1,26 @@
export type ButtonTextMap = Record<string, string>;
export function selectButtonText(locale: string | undefined, texts: ButtonTextMap): string {
const normalizedLocale = locale?.trim();
if (normalizedLocale && texts[normalizedLocale]) {
return texts[normalizedLocale];
}
const language = normalizedLocale?.split("-")[0];
if (language) {
if (texts[language]) {
return texts[language];
}
const languageMatch = Object.entries(texts).find(([key]) => key.split("-")[0] === language);
if (languageMatch) {
return languageMatch[1];
}
}
if (texts.en) {
return texts.en;
}
return Object.values(texts)[0] ?? "";
}

19
app/root.tsx Normal file
View File

@ -0,0 +1,19 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

34
app/routes/_index.tsx Normal file
View File

@ -0,0 +1,34 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../shopify.server";
export async function loader({ request }: LoaderFunctionArgs) {
try {
await authenticate.admin(request);
} catch {
// Local mock development can still render this status page before app auth is configured.
}
return json({
appName: "Dreame Activity",
mode: process.env.THIRD_PARTY_MODE || "mock",
});
}
export default function Index() {
const data = useLoaderData<typeof loader>();
return (
<main style={{ fontFamily: "system-ui, sans-serif", padding: 32, maxWidth: 760 }}>
<h1>{data.appName}</h1>
<p>Shopify Thank You Page activity app scaffold.</p>
<p>
Third party mode: <strong>{data.mode}</strong>
</p>
<p>
Configure Metaobject <code>dreame_activity</code>, install the Checkout UI Extension, and use mock mode for
local validation before live integration.
</p>
</main>
);
}

View File

@ -0,0 +1,104 @@
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { v4 as uuidv4 } from "uuid";
import { authenticate, unauthenticated } from "../shopify.server";
import { runActivityCheck } from "../services/activity.server";
import { getActivityConfig } from "../services/config.server";
import { callPush, callStatus } from "../services/dreame-client.server";
import { findSuccessfulPush, recordSuccessfulPush } from "../services/idempotency.server";
import { logActivityPush } from "../services/logger.server";
import { getOrderDetails } from "../services/order.server";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
type ActivityRequestBody = {
orderId?: string;
orderNumber?: string;
locale?: string;
};
export async function loader({ request }: LoaderFunctionArgs) {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
return json({ success: false }, { status: 405, headers: CORS_HEADERS });
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
if (request.method !== "POST") {
return json({ success: false }, { status: 405, headers: CORS_HEADERS });
}
try {
const checkoutAuth = (authenticate as unknown as { public?: { checkout?: (request: Request) => Promise<unknown> } })
.public?.checkout;
if (!checkoutAuth) {
throw new Error("Shopify checkout authentication is unavailable");
}
const authResult = (await checkoutAuth(request)) as {
shop?: string;
admin?: unknown;
sessionToken?: { dest?: string; iss?: string };
session?: { shop?: string };
};
const shop = authResult.shop || authResult.session?.shop || shopFromSessionToken(authResult.sessionToken);
const adminResult = authResult.admin ? { admin: authResult.admin } : shop ? await unauthenticated.admin(shop) : null;
const admin = adminResult?.admin as {
graphql: (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
};
if (!shop || !admin) {
throw new Error("Invalid Shopify checkout session");
}
const body = (await request.json()) as ActivityRequestBody;
if (!body.orderId) {
return json({ success: false }, { headers: CORS_HEADERS });
}
const result = await runActivityCheck(
{
shop,
orderId: body.orderId,
orderNumber: body.orderNumber,
locale: body.locale,
},
{
nowMs: () => Date.now(),
uuid: uuidv4,
getOrder: (_shop, orderId) => getOrderDetails(admin, orderId),
getConfig: () => getActivityConfig(admin),
findSuccess: findSuccessfulPush,
recordSuccess: recordSuccessfulPush,
log: logActivityPush,
callStatus,
callPush,
},
);
return json(result, { headers: CORS_HEADERS });
} catch {
return json({ success: false }, { headers: CORS_HEADERS });
}
}
function shopFromSessionToken(sessionToken: { dest?: string; iss?: string } | undefined): string | undefined {
const source = sessionToken?.dest || sessionToken?.iss;
if (!source) {
return undefined;
}
try {
return new URL(source).hostname;
} catch {
return source.replace(/^https?:\/\//, "").split("/")[0];
}
}

View File

@ -0,0 +1,287 @@
import { selectButtonText } from "../lib/locale";
import type { ButtonTextMap } from "../lib/locale";
export type ActivityRequest = {
shop: string;
orderId: string;
orderNumber?: string;
locale?: string;
};
export type ActivityConfig = {
activityId: number;
apiAppId: string;
apiSecret: string;
statusApiUrl: string;
pushApiUrl: string;
redirectUrl: string;
buttonText: ButtonTextMap;
shop?: string;
};
export type OrderDetails = {
id: string;
legacyId: string;
orderNumber?: string;
email?: string;
customerId?: string;
currency: string;
shopDomain: string;
};
export type StatusResponse = {
code?: number;
data?: {
status?: boolean;
trigger?: string[];
};
msg?: string;
};
export type PushResponse = {
code?: number;
data?: unknown;
msg?: string;
};
export type ThirdPartyResult<TBody> = {
httpOk: boolean;
status: number;
body: TBody;
requestBody: string;
responseBody: string;
};
export type PushPayload = {
activity_id: number;
event_type: "order_end";
created_at: number;
user_id: string;
email: string;
event_id: string;
properties: {
shop: string;
currency: string;
order_id: string;
order_number: string;
};
};
export type LogEntry = {
shop: string;
orderId: string;
customerId?: string;
email?: string;
activityId: string;
eventType: string;
eventId: string;
status: "success" | "fail";
requestBody?: string;
responseBody?: string;
errorMessage?: string;
};
export type IdempotencyHit = {
eventId: string;
redirectUrl: string;
};
export type ActivityDeps = {
nowMs: () => number;
uuid: () => string;
getOrder: (shop: string, orderId: string) => Promise<OrderDetails>;
getConfig: (shop: string) => Promise<ActivityConfig>;
findSuccess: (shop: string, orderId: string, activityId: number) => Promise<IdempotencyHit | null>;
recordSuccess: (entry: {
shop: string;
orderId: string;
activityId: number;
eventType: "order_end";
eventId: string;
redirectUrl: string;
buttonText: string;
}) => Promise<void>;
log: (entry: LogEntry) => Promise<void>;
callStatus: (config: ActivityConfig) => Promise<ThirdPartyResult<StatusResponse>>;
callPush: (config: ActivityConfig, payload: PushPayload) => Promise<ThirdPartyResult<PushResponse>>;
};
export type ActivityResponse =
| {
success: true;
redirectUrl: string;
buttonText: string;
}
| {
success: false;
};
const ORDER_END_EVENT = "order_end" as const;
export async function runActivityCheck(request: ActivityRequest, deps: ActivityDeps): Promise<ActivityResponse> {
const normalizedOrderId = normalizeOrderId(request.orderId);
let order: OrderDetails | undefined;
let config: ActivityConfig | undefined;
let eventId = "";
try {
order = await deps.getOrder(request.shop, normalizedOrderId);
config = await deps.getConfig(request.shop);
const buttonText = selectButtonText(request.locale, config.buttonText);
const idempotency = await deps.findSuccess(request.shop, normalizedOrderId, config.activityId);
if (idempotency) {
return {
success: true,
redirectUrl: config.redirectUrl,
buttonText,
};
}
if (!order.email) {
await deps.log(failureLog(request.shop, order, config, eventId, "Order email is missing"));
return { success: false };
}
if (!order.customerId) {
await deps.log(failureLog(request.shop, order, config, eventId, "Order customer id is missing"));
return { success: false };
}
const statusResult = await deps.callStatus(config);
if (!isActiveStatus(statusResult)) {
await deps.log(
failureLog(
request.shop,
order,
config,
eventId,
"Activity status API did not return active status",
statusResult.requestBody,
statusResult.responseBody,
),
);
return { success: false };
}
eventId = deps.uuid();
const pushPayload: PushPayload = {
activity_id: config.activityId,
event_type: ORDER_END_EVENT,
created_at: deps.nowMs(),
user_id: order.customerId,
email: order.email,
event_id: eventId,
properties: {
shop: config.shop || order.shopDomain,
currency: order.currency,
order_id: order.legacyId,
order_number: order.orderNumber || request.orderNumber || order.legacyId,
},
};
const pushResult = await deps.callPush(config, pushPayload);
if (!isSuccessfulPush(pushResult)) {
await deps.log(
failureLog(
request.shop,
order,
config,
eventId,
"Activity push API failed",
pushResult.requestBody,
pushResult.responseBody,
),
);
return { success: false };
}
await deps.recordSuccess({
shop: request.shop,
orderId: normalizedOrderId,
activityId: config.activityId,
eventType: ORDER_END_EVENT,
eventId,
redirectUrl: config.redirectUrl,
buttonText,
});
await deps.log({
shop: request.shop,
orderId: normalizedOrderId,
customerId: order.customerId,
email: order.email,
activityId: config.activityId.toString(),
eventType: ORDER_END_EVENT,
eventId,
status: "success",
requestBody: pushResult.requestBody,
responseBody: pushResult.responseBody,
});
return {
success: true,
redirectUrl: config.redirectUrl,
buttonText,
};
} catch (error) {
await safeLog(deps, failureLog(request.shop, order, config, eventId, toErrorMessage(error)));
return { success: false };
}
}
export function normalizeOrderId(orderId: string): string {
if (orderId.startsWith("gid://shopify/Order/")) {
return orderId;
}
return `gid://shopify/Order/${orderId.replace(/[^0-9]/g, "")}`;
}
export function extractLegacyId(orderId: string): string {
return orderId.split("/").pop() || orderId;
}
function isActiveStatus(result: ThirdPartyResult<StatusResponse>): boolean {
return result.httpOk && result.body.code === 0 && result.body.data?.status === true;
}
function isSuccessfulPush(result: ThirdPartyResult<PushResponse>): boolean {
return result.httpOk && result.body.code === 0;
}
function failureLog(
shop: string,
order: OrderDetails | undefined,
config: ActivityConfig | undefined,
eventId: string,
errorMessage: string,
requestBody?: string,
responseBody?: string,
): LogEntry {
return {
shop,
orderId: order?.id || "",
customerId: order?.customerId,
email: order?.email,
activityId: config?.activityId.toString() || "",
eventType: ORDER_END_EVENT,
eventId,
status: "fail",
requestBody,
responseBody,
errorMessage,
};
}
async function safeLog(deps: ActivityDeps, entry: LogEntry): Promise<void> {
try {
await deps.log(entry);
} catch {
// Logging must not break Thank You Page behavior.
}
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@ -0,0 +1,77 @@
import type { ActivityConfig } from "./activity.server";
type AdminGraphqlClient = {
graphql: (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
};
type MetaobjectField = {
key: string;
value: string | null;
};
const METAOBJECT_QUERY = `#graphql
query DreameActivityConfig($type: String!, $handle: String!) {
metaobjectByHandle(handle: { type: $type, handle: $handle }) {
fields {
key
value
}
}
}
`;
export async function getActivityConfig(admin: AdminGraphqlClient): Promise<ActivityConfig> {
const type = process.env.METAOBJECT_TYPE || "dreame_activity";
const handle = process.env.METAOBJECT_HANDLE || "dreame-activity-config";
const response = await admin.graphql(METAOBJECT_QUERY, { variables: { type, handle } });
const payload = (await response.json()) as {
data?: { metaobjectByHandle?: { fields?: MetaobjectField[] } | null };
errors?: unknown;
};
const fields = payload.data?.metaobjectByHandle?.fields;
if (!fields?.length) {
throw new Error(`Dreame activity metaobject not found: ${type}/${handle}`);
}
const values = Object.fromEntries(fields.map((field) => [field.key, field.value]));
const buttonText = parseButtonText(values.button_text);
const activityId = Number(values.activity_id);
if (!Number.isFinite(activityId)) {
throw new Error("Metaobject field activity_id is required");
}
return {
activityId,
apiAppId: requireString(values.api_app_id, "api_app_id"),
apiSecret: requireString(values.api_secret, "api_secret"),
statusApiUrl: requireString(values.status_api_url, "status_api_url"),
pushApiUrl: requireString(values.push_api_url, "push_api_url"),
redirectUrl: requireString(values.redirect_url, "redirect_url"),
buttonText,
shop: values.shop || undefined,
};
}
function requireString(value: string | null | undefined, key: string): string {
if (!value) {
throw new Error(`Metaobject field ${key} is required`);
}
return value;
}
function parseButtonText(value: string | null | undefined): Record<string, string> {
if (!value) {
throw new Error("Metaobject field button_text is required");
}
const parsed = JSON.parse(value) as Record<string, unknown>;
const entries = Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string");
if (!entries.length) {
throw new Error("Metaobject field button_text must contain at least one locale");
}
return Object.fromEntries(entries);
}

View File

@ -0,0 +1,96 @@
import { buildSignedRequest } from "./signature.server";
import type { ActivityConfig, PushPayload, PushResponse, StatusResponse, ThirdPartyResult } from "./activity.server";
export async function callStatus(config: ActivityConfig): Promise<ThirdPartyResult<StatusResponse>> {
const payload = { activity_id: config.activityId };
return callDreame<StatusResponse>(config.statusApiUrl, config, payload, mockStatusResponse);
}
export async function callPush(config: ActivityConfig, payload: PushPayload): Promise<ThirdPartyResult<PushResponse>> {
return callDreame<PushResponse>(config.pushApiUrl, config, payload, mockPushResponse);
}
async function callDreame<TBody>(
url: string,
config: ActivityConfig,
payload: unknown,
mockFactory: () => TBody,
): Promise<ThirdPartyResult<TBody>> {
const signed = buildSignedRequest(payload, {
apiAppId: config.apiAppId,
apiSecret: config.apiSecret,
});
if ((process.env.THIRD_PARTY_MODE || "mock") === "mock") {
if (process.env.DREAME_MOCK_TIMEOUT === "true") {
return {
httpOk: false,
status: 408,
body: { code: 408, msg: "mock timeout" } as TBody,
requestBody: signed.bodyString,
responseBody: '{"code":408,"msg":"mock timeout"}',
};
}
const body = mockFactory();
return {
httpOk: true,
status: 200,
body,
requestBody: signed.bodyString,
responseBody: JSON.stringify(body),
};
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number(process.env.THIRD_PARTY_TIMEOUT_MS || 5000));
try {
const response = await fetch(url, {
method: "POST",
headers: signed.headers,
body: signed.bodyString,
signal: controller.signal,
});
const responseText = await response.text();
const body = parseJson(responseText) as TBody;
return {
httpOk: response.ok,
status: response.status,
body,
requestBody: signed.bodyString,
responseBody: responseText,
};
} finally {
clearTimeout(timeout);
}
}
function mockStatusResponse(): StatusResponse {
return {
code: Number(process.env.DREAME_MOCK_STATUS_CODE || 0),
data: {
status: process.env.DREAME_MOCK_STATUS_ACTIVE !== "false",
trigger: ["order_end", "card_add"],
},
};
}
function mockPushResponse(): PushResponse {
return {
code: Number(process.env.DREAME_MOCK_PUSH_CODE || 0),
};
}
function parseJson(text: string): unknown {
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch {
return { code: -1, msg: text };
}
}

View File

@ -0,0 +1,57 @@
import db from "../db.server";
export async function findSuccessfulPush(shop: string, orderId: string, activityId: number) {
const record = await db.activityPushIdempotency.findUnique({
where: {
shop_orderId_activityId: {
shop,
orderId,
activityId: activityId.toString(),
},
},
});
if (!record) {
return null;
}
return {
eventId: record.eventId,
redirectUrl: record.redirectUrl,
};
}
export async function recordSuccessfulPush(entry: {
shop: string;
orderId: string;
activityId: number;
eventType: "order_end";
eventId: string;
redirectUrl: string;
buttonText: string;
}) {
await db.activityPushIdempotency.upsert({
where: {
shop_orderId_activityId: {
shop: entry.shop,
orderId: entry.orderId,
activityId: entry.activityId.toString(),
},
},
update: {
eventId: entry.eventId,
redirectUrl: entry.redirectUrl,
buttonText: entry.buttonText,
pushedAt: new Date(),
},
create: {
shop: entry.shop,
orderId: entry.orderId,
activityId: entry.activityId.toString(),
eventType: entry.eventType,
eventId: entry.eventId,
redirectUrl: entry.redirectUrl,
buttonText: entry.buttonText,
},
});
}

View File

@ -0,0 +1,66 @@
import db from "../db.server";
import type { LogEntry } from "./activity.server";
const SENSITIVE_KEYS = new Set(["api_secret", "app_secret", "apiSecret", "dreame-api-sign", "sign", "signature"]);
export async function logActivityPush(entry: LogEntry): Promise<void> {
await db.activityPushLog.create({
data: {
shop: entry.shop,
orderId: entry.orderId || "unknown",
customerId: entry.customerId,
email: entry.email,
activityId: entry.activityId || "unknown",
eventType: entry.eventType,
eventId: entry.eventId || "not-generated",
status: entry.status,
requestBody: sanitizeBody(entry.requestBody),
responseBody: sanitizeBody(entry.responseBody),
errorMessage: entry.errorMessage,
},
});
if (Math.random() < 0.05) {
await pruneLogs();
}
}
export async function pruneLogs(days = Number(process.env.LOG_RETENTION_DAYS || 7)): Promise<void> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
await db.activityPushLog.deleteMany({
where: {
createdAt: {
lt: cutoff,
},
},
});
}
export function sanitizeBody(body: string | undefined): string | undefined {
if (!body) {
return undefined;
}
try {
return JSON.stringify(maskSensitive(JSON.parse(body)));
} catch {
return body.replace(/[a-f0-9]{32}/gi, "[redacted-md5]");
}
}
function maskSensitive(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(maskSensitive);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => [
key,
SENSITIVE_KEYS.has(key) ? "[redacted]" : maskSensitive(child),
]),
);
}
return value;
}

View File

@ -0,0 +1,59 @@
import { extractLegacyId, normalizeOrderId } from "./activity.server";
import type { OrderDetails } from "./activity.server";
type AdminGraphqlClient = {
graphql: (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
};
const ORDER_QUERY = `#graphql
query DreameOrder($id: ID!) {
order: node(id: $id) {
... on Order {
id
name
email
currencyCode
customer {
id
}
}
}
shop {
myshopifyDomain
}
}
`;
export async function getOrderDetails(admin: AdminGraphqlClient, orderId: string): Promise<OrderDetails> {
const normalizedOrderId = normalizeOrderId(orderId);
const response = await admin.graphql(ORDER_QUERY, { variables: { id: normalizedOrderId } });
const payload = (await response.json()) as {
data?: {
order?: {
id: string;
name?: string;
email?: string;
currencyCode?: string;
customer?: { id?: string } | null;
} | null;
shop?: {
myshopifyDomain?: string;
};
};
};
const order = payload.data?.order;
if (!order) {
throw new Error(`Order not found: ${normalizedOrderId}`);
}
return {
id: order.id,
legacyId: extractLegacyId(order.id),
orderNumber: order.name,
email: order.email || undefined,
customerId: order.customer?.id ? extractLegacyId(order.customer.id) : undefined,
currency: order.currencyCode || "",
shopDomain: payload.data?.shop?.myshopifyDomain || "",
};
}

View File

@ -0,0 +1,35 @@
import { createHash } from "crypto";
export type SignOptions = {
apiAppId: string;
apiSecret: string;
timestamp?: string;
};
export type SignedRequest = {
bodyString: string;
headers: Record<string, string>;
};
export function stringifyBody(payload: unknown): string {
return JSON.stringify(payload);
}
export function dreameSign(bodyString: string, timestamp: string, apiSecret: string): string {
return createHash("md5").update(Buffer.from(bodyString + timestamp + apiSecret, "utf8")).digest("hex");
}
export function buildSignedRequest(payload: unknown, options: SignOptions): SignedRequest {
const bodyString = stringifyBody(payload);
const timestamp = options.timestamp ?? Date.now().toString();
return {
bodyString,
headers: {
"content-type": "application/json",
"dreame-api-app-id": options.apiAppId,
"dreame-api-timestamp": timestamp,
"dreame-api-sign": dreameSign(bodyString, timestamp, options.apiSecret),
},
};
}

23
app/shopify.server.ts Normal file
View File

@ -0,0 +1,23 @@
import "@shopify/shopify-app-remix/adapters/node";
import { AppDistribution, shopifyApp } from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import db from "./db.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY || "",
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: "2026-01" as never,
scopes: (process.env.SCOPES || "read_orders,read_metaobjects").split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(db),
distribution: AppDistribution.Custom,
future: {
unstable_newEmbeddedAuthStrategy: true,
},
});
export default shopify;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;

View File

@ -0,0 +1,20 @@
api_version = "2026-01"
[[extensions]]
name = "Dreame Thank You Activity"
handle = "thank-you-activity"
type = "ui_extension"
[[extensions.targeting]]
module = "./src/Checkout.tsx"
target = "purchase.thank-you.block.render"
[extensions.capabilities]
network_access = true
[extensions.settings]
[[extensions.settings.fields]]
key = "app_url"
type = "single_line_text_field"
name = "App URL"
description = "Public HTTPS URL for the Shopify App backend, for example https://app.example.com"

View File

@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { reactExtension, Button, InlineStack, useApi } from "@shopify/ui-extensions-react/checkout";
type ActivityResponse =
| {
success: true;
redirectUrl: string;
buttonText: string;
}
| {
success: false;
};
export default reactExtension("purchase.thank-you.block.render", () => <DreameActivity />);
function DreameActivity() {
const api = useApi<"purchase.thank-you.block.render">() as any;
const [response, setResponse] = useState<ActivityResponse>({ success: false });
useEffect(() => {
let cancelled = false;
async function checkActivity() {
const appUrl = String(api.settings?.current?.app_url || "").replace(/\/$/, "");
const order = api.orderConfirmation?.current?.order || api.orderConfirmation?.value?.order;
const orderId = order?.id;
if (!appUrl || !orderId) {
return;
}
try {
const token = await api.sessionToken.get();
const locale =
api.localization?.current?.language?.isoCode ||
api.localization?.value?.language?.isoCode ||
api.i18n?.locale ||
"en";
const result = (await fetch(`${appUrl}/api/action/activity/check`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
orderId,
orderNumber: order?.name,
locale,
}),
}).then((res) => res.json())) as ActivityResponse;
if (!cancelled) {
setResponse(result.success ? result : { success: false });
}
} catch {
if (!cancelled) {
setResponse({ success: false });
}
}
}
void checkActivity();
return () => {
cancelled = true;
};
}, [api]);
if (!response.success) {
return null;
}
return (
<InlineStack>
<Button to={response.redirectUrl}>{response.buttonText}</Button>
</InlineStack>
);
}

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "dreame-shopify-app",
"private": true,
"scripts": {
"build": "remix vite:build",
"dev": "shopify app dev",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^6.10.1",
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
"@shopify/app-bridge-react": "^4.2.2",
"@shopify/polaris": "^12.27.0",
"@shopify/shopify-app-remix": "^3.8.2",
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
"@shopify/ui-extensions-react": "^2026.1.0",
"isbot": "^5.1.28",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@remix-run/dev": "^2.16.8",
"@types/node": "^22.15.32",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.5.2",
"prisma": "^6.10.1",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=20.0.0"
}
}

60
prisma/schema.prisma Normal file
View File

@ -0,0 +1,60 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
}
model ActivityPushLog {
id String @id @default(cuid())
shop String
orderId String
customerId String?
email String?
activityId String
eventType String
eventId String
status String
requestBody String?
responseBody String?
errorMessage String?
createdAt DateTime @default(now())
@@index([shop, orderId])
@@index([createdAt])
}
model ActivityPushIdempotency {
id String @id @default(cuid())
shop String
orderId String
activityId String
eventType String
eventId String
redirectUrl String
buttonText String
pushedAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([shop, orderId, activityId])
}

105
scripts/setup-metaobject.ts Normal file
View File

@ -0,0 +1,105 @@
type GraphqlResponse<T> = {
data?: T;
errors?: unknown;
};
const shop = requiredEnv("SHOPIFY_SHOP_DOMAIN");
const token = requiredEnv("SHOPIFY_ADMIN_ACCESS_TOKEN");
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
mutation DreameMetaobjectCreate($metaobject: MetaobjectCreateInput!) {
metaobjectCreate(metaobject: $metaobject) {
metaobject {
id
handle
}
userErrors {
field
message
}
}
}
`;
async function main() {
await graphql(definitionMutation, {
definition: {
type: "dreame_activity",
name: "Dreame Activity",
access: { storefront: "NONE" },
fieldDefinitions: [
{ key: "activity_id", name: "Activity ID", type: "number_integer", required: true },
{ key: "api_app_id", name: "API App ID", type: "single_line_text_field", required: true },
{ 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: {
type: "dreame_activity",
handle: "dreame-activity-config",
fields: [
{ key: "activity_id", value: "12" },
{ key: "api_app_id", value: "mock-app-id" },
{ key: "api_secret", value: "mock-secret" },
{ key: "status_api_url", value: "https://third-party.example/status" },
{ key: "push_api_url", value: "https://third-party.example/push" },
{ key: "redirect_url", value: "https://activity.example.com" },
{ key: "button_text", value: JSON.stringify({ en: "Claim Your Reward", "zh-CN": "领取奖励" }) },
{ key: "shop", value: shop },
],
},
});
}
async function graphql<T>(query: string, variables: Record<string, unknown>): Promise<GraphqlResponse<T>> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": token,
},
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json()) as GraphqlResponse<T>;
if (!response.ok || payload.errors) {
throw new Error(JSON.stringify(payload.errors || payload));
}
console.log(JSON.stringify(payload, null, 2));
return payload;
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

18
shopify.app.toml Normal file
View File

@ -0,0 +1,18 @@
name = "Dreame Activity"
handle = "dreame-activity"
application_url = "https://example.ngrok-free.app"
embedded = true
[build]
automatically_update_urls_on_dev = true
include_config_on_deploy = true
[access_scopes]
scopes = "read_orders,read_metaobjects"
[auth]
redirect_urls = [
"https://example.ngrok-free.app/auth/callback",
"https://example.ngrok-free.app/auth/shopify/callback",
"https://example.ngrok-free.app/api/auth/callback"
]

195
test/activity-flow.test.ts Normal file
View File

@ -0,0 +1,195 @@
import { describe, expect, test } from "vitest";
import { runActivityCheck } from "../app/services/activity.server";
import type { ActivityDeps, PushPayload } from "../app/services/activity.server";
function baseDeps(overrides: Partial<ActivityDeps> = {}): ActivityDeps {
return {
nowMs: () => 1776901800000,
uuid: () => "00000000-0000-4000-8000-000000000000",
getOrder: async () => ({
id: "gid://shopify/Order/123",
legacyId: "123",
orderNumber: "#1001",
email: "customer@example.com",
customerId: "99999",
currency: "USD",
shopDomain: "demo.myshopify.com",
}),
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": "领取奖励" },
shop: "configured-shop",
}),
findSuccess: async () => null,
recordSuccess: async () => undefined,
log: async () => undefined,
callStatus: async () => ({
httpOk: true,
status: 200,
body: { code: 0, data: { status: true, trigger: ["card_add"] } },
requestBody: '{"activity_id":12}',
responseBody: '{"code":0,"data":{"status":true}}',
}),
callPush: async () => ({
httpOk: true,
status: 200,
body: { code: 0 },
requestBody: "{}",
responseBody: '{"code":0}',
}),
...overrides,
};
}
describe("activity flow", () => {
test("returns false when status api says activity is inactive", async () => {
let pushCalls = 0;
const result = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" },
baseDeps({
callStatus: async () => ({
httpOk: true,
status: 200,
body: { code: 0, data: { status: false, trigger: ["order_end"] } },
requestBody: '{"activity_id":12}',
responseBody: '{"code":0,"data":{"status":false}}',
}),
callPush: async () => {
pushCalls += 1;
return { httpOk: true, status: 200, body: { code: 0 }, requestBody: "{}", responseBody: "{}" };
},
}),
);
expect(result).toEqual({ success: false });
expect(pushCalls).toBe(0);
});
test("returns false when status api code is not zero", async () => {
const result = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" },
baseDeps({
callStatus: async () => ({
httpOk: true,
status: 200,
body: { code: 401, data: {}, msg: "auth required" },
requestBody: '{"activity_id":12}',
responseBody: '{"code":401}',
}),
}),
);
expect(result).toEqual({ success: false });
});
test("pushes order_end with configured shop and no line items", async () => {
let captured: PushPayload | null = null;
const result = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "gid://shopify/Order/123", orderNumber: "#1001", locale: "zh-CN" },
baseDeps({
callPush: async (_config, payload) => {
captured = payload;
return {
httpOk: true,
status: 200,
body: { code: 0 },
requestBody: JSON.stringify(payload),
responseBody: '{"code":0}',
};
},
}),
);
expect(result).toEqual({
success: true,
redirectUrl: "https://activity.example.com",
buttonText: "领取奖励",
});
expect(captured).toMatchObject({
activity_id: 12,
event_type: "order_end",
created_at: 1776901800000,
user_id: "99999",
email: "customer@example.com",
event_id: "00000000-0000-4000-8000-000000000000",
properties: {
shop: "configured-shop",
currency: "USD",
order_id: "123",
order_number: "#1001",
},
});
expect(captured?.properties).not.toHaveProperty("lineItems");
expect(captured?.event_id).toMatch(/^[0-9a-f-]{36}$/);
});
test("returns false when push api fails", async () => {
const result = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" },
baseDeps({
callPush: async () => ({
httpOk: true,
status: 200,
body: { code: 500 },
requestBody: "{}",
responseBody: '{"code":500}',
}),
}),
);
expect(result).toEqual({ success: false });
});
test("does not push when email is missing", async () => {
let pushCalls = 0;
const result = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" },
baseDeps({
getOrder: async () => ({
id: "gid://shopify/Order/123",
legacyId: "123",
orderNumber: "#1001",
customerId: "99999",
currency: "USD",
shopDomain: "demo.myshopify.com",
}),
callPush: async () => {
pushCalls += 1;
return { httpOk: true, status: 200, body: { code: 0 }, requestBody: "{}", responseBody: "{}" };
},
}),
);
expect(result).toEqual({ success: false });
expect(pushCalls).toBe(0);
});
test("does not push when customer id is missing", async () => {
let pushCalls = 0;
const result = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "123", orderNumber: "#1001", locale: "en" },
baseDeps({
getOrder: async () => ({
id: "gid://shopify/Order/123",
legacyId: "123",
orderNumber: "#1001",
email: "customer@example.com",
currency: "USD",
shopDomain: "demo.myshopify.com",
}),
callPush: async () => {
pushCalls += 1;
return { httpOk: true, status: 200, body: { code: 0 }, requestBody: "{}", responseBody: "{}" };
},
}),
);
expect(result).toEqual({ success: false });
expect(pushCalls).toBe(0);
});
});

95
test/idempotency.test.ts Normal file
View File

@ -0,0 +1,95 @@
import { describe, expect, test } from "vitest";
import { runActivityCheck } from "../app/services/activity.server";
import type { ActivityDeps } from "../app/services/activity.server";
function makeDeps(overrides: Partial<ActivityDeps> = {}): ActivityDeps {
let pushed = false;
return {
nowMs: () => 1776901800000,
uuid: () => "00000000-0000-4000-8000-000000000000",
getOrder: async () => ({
id: "gid://shopify/Order/123",
legacyId: "123",
orderNumber: "#1001",
email: "customer@example.com",
customerId: "99999",
currency: "USD",
shopDomain: "demo.myshopify.com",
}),
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": "领取奖励" },
shop: "configured-shop",
}),
findSuccess: async () =>
pushed
? {
eventId: "00000000-0000-4000-8000-000000000000",
redirectUrl: "https://activity.example.com",
}
: null,
recordSuccess: async () => {
pushed = true;
},
log: async () => undefined,
callStatus: async () => ({
httpOk: true,
status: 200,
body: { code: 0, data: { status: true, trigger: ["card_add"] } },
requestBody: '{"activity_id":12}',
responseBody: '{"code":0,"data":{"status":true}}',
}),
callPush: async () => ({
httpOk: true,
status: 200,
body: { code: 0 },
requestBody: "{}",
responseBody: '{"code":0}',
}),
...overrides,
};
}
describe("activity idempotency", () => {
test("does not call push twice for the same order and still returns button data", async () => {
let pushCalls = 0;
const deps = makeDeps({
callPush: async () => {
pushCalls += 1;
return {
httpOk: true,
status: 200,
body: { code: 0 },
requestBody: "{}",
responseBody: '{"code":0}',
};
},
});
const first = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "gid://shopify/Order/123", orderNumber: "#1001", locale: "zh-CN" },
deps,
);
const second = await runActivityCheck(
{ shop: "demo.myshopify.com", orderId: "gid://shopify/Order/123", orderNumber: "#1001", locale: "zh-CN" },
deps,
);
expect(first).toEqual({
success: true,
redirectUrl: "https://activity.example.com",
buttonText: "领取奖励",
});
expect(second).toEqual({
success: true,
redirectUrl: "https://activity.example.com",
buttonText: "领取奖励",
});
expect(pushCalls).toBe(1);
});
});

35
test/signature.test.ts Normal file
View File

@ -0,0 +1,35 @@
import { createHash } from "crypto";
import { describe, expect, test } from "vitest";
import { buildSignedRequest, dreameSign, stringifyBody } from "../app/services/signature.server";
describe("dreame signature", () => {
test("generates stable 32-char lowercase md5", () => {
const bodyString = '{"activity_id":12}';
const timestamp = "1776901800000";
const secret = "abc123secret";
const expected = createHash("md5")
.update(Buffer.from(`${bodyString}${timestamp}${secret}`, "utf8"))
.digest("hex");
const sign = dreameSign(bodyString, timestamp, secret);
expect(sign).toBe(expected);
expect(sign).toMatch(/^[a-f0-9]{32}$/);
});
test("uses the same serialized body for signing and fetch", () => {
const payload = { activity_id: 12 };
const bodyString = stringifyBody(payload);
const signed = buildSignedRequest(payload, {
apiAppId: "app-id",
apiSecret: "secret",
timestamp: "1776901800000",
});
expect(signed.bodyString).toBe(bodyString);
expect(signed.bodyString).toBe('{"activity_id":12}');
expect(signed.headers["dreame-api-sign"]).toBe(
dreameSign(bodyString, "1776901800000", "secret"),
);
});
});

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"include": ["app/**/*.ts", "app/**/*.tsx", "extensions/**/*.ts", "extensions/**/*.tsx", "test/**/*.ts", "vite.config.ts", "vitest.config.ts"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vitest/globals"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2022",
"strict": true,
"allowJs": false,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"noEmit": true
}
}

12
vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
remix({
ignoredRouteFiles: ["**/.*"],
}),
tsconfigPaths(),
],
});

11
vitest.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
globals: true,
include: ["test/**/*.test.ts"],
},
});