Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/collect-product-price-history.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Collect Product Price History

on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:

concurrency:
group: collect-product-price-history
cancel-in-progress: false

jobs:
sample:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Record lowest available product prices
env:
COLLECT_PRICES_URL: ${{ secrets.COLLECT_PRICES_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
run: |
set -euo pipefail

if [ -z "$COLLECT_PRICES_URL" ] || [ -z "$CRON_SECRET" ]; then
echo "缺少 COLLECT_PRICES_URL 或 CRON_SECRET secret"
exit 1
fi

BASE_URL=$(node <<'NODE'
const url = new URL(process.env.COLLECT_PRICES_URL);
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/api\/cron\/[^/]+\/?$/, "") || "/";
console.log(url.toString().replace(/\/$/, ""));
NODE
)

SAMPLE_URL="$BASE_URL/api/cron/product-price-history?action=sample" node <<'NODE'
const response = await fetch(process.env.SAMPLE_URL, {
method: "POST",
headers: { authorization: `Bearer ${process.env.CRON_SECRET}` },
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}

if (!response.ok || payload?.ok === false) {
throw new Error(payload?.message || text || `HTTP ${response.status}`);
}

console.log(JSON.stringify({
action: payload.action,
result: payload.result,
startedAt: payload.startedAt,
finishedAt: payload.finishedAt,
}));
NODE

- name: Notify failure
if: failure()
env:
WEBHOOK_URL: ${{ secrets.PRICEAI_ALERT_WEBHOOK_URL }}
run: |
if [ -z "$WEBHOOK_URL" ]; then
exit 0
fi

node <<'NODE'
await fetch(process.env.WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "PriceAI 商品价格历史采样失败",
message: `${process.env.GITHUB_WORKFLOW} #${process.env.GITHUB_RUN_NUMBER} failed on ${process.env.GITHUB_REF_NAME}`,
severity: "warning",
source: "github-actions",
runUrl: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
}),
});
NODE
60 changes: 60 additions & 0 deletions .github/workflows/maintenance-retention.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,66 @@ jobs:
}));
NODE

- name: Apply product price history retention
env:
COLLECT_PRICES_URL: ${{ secrets.COLLECT_PRICES_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
INPUT_BATCH_SIZE: ${{ inputs.batch_size }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail

if [ -z "$COLLECT_PRICES_URL" ] || [ -z "$CRON_SECRET" ]; then
echo "缺少 COLLECT_PRICES_URL 或 CRON_SECRET secret"
exit 1
fi

BASE_URL=$(node <<'NODE'
const url = new URL(process.env.COLLECT_PRICES_URL);
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/api\/cron\/[^/]+\/?$/, "") || "/";
console.log(url.toString().replace(/\/$/, ""));
NODE
)

BATCH_SIZE="${INPUT_BATCH_SIZE:-5000}"
if ! [[ "$BATCH_SIZE" =~ ^[0-9]+$ ]] || [ "$BATCH_SIZE" -lt 100 ] || [ "$BATCH_SIZE" -gt 20000 ]; then
echo "batch_size 必须是 100 到 20000 之间的整数"
exit 1
fi

APPLY=1
if [ "${INPUT_DRY_RUN:-false}" = "true" ]; then
APPLY=0
fi

RETENTION_URL="$BASE_URL/api/cron/product-price-history?action=prune&apply=$APPLY&batch=$BATCH_SIZE" node <<'NODE'
const response = await fetch(process.env.RETENTION_URL, {
method: "POST",
headers: { authorization: `Bearer ${process.env.CRON_SECRET}` },
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}

if (!response.ok || payload?.ok === false) {
throw new Error(payload?.message || text || `HTTP ${response.status}`);
}

console.log(JSON.stringify({
dryRun: payload.dryRun,
batchSize: payload.batchSize,
result: payload.result,
startedAt: payload.startedAt,
finishedAt: payload.finishedAt,
}));
NODE

- name: Notify failure
if: failure()
env:
Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"start": "node_modules/node/bin/node node_modules/next/dist/bin/next start",
"lint": "node_modules/node/bin/node node_modules/eslint/bin/eslint.js",
"typecheck": "node_modules/node/bin/node node_modules/next/dist/bin/next typegen && node_modules/node/bin/node node_modules/typescript/bin/tsc --noEmit",
"test": "npm run test:auth-security && npm run test:feedback && npm run test:wholesale && npm run test:channel-submissions && npm run test:api-transit && npm run test:product-offer-pagination && npm run test:catalog && npm run test:api-models && npm run test:official-parser && npm run test:buyer-fee",
"test": "npm run test:auth-security && npm run test:feedback && npm run test:wholesale && npm run test:channel-submissions && npm run test:api-transit && npm run test:product-offer-pagination && npm run test:price-history && npm run test:catalog && npm run test:api-models && npm run test:official-parser && npm run test:buyer-fee",
"collect:prices": "node scripts/collect-prices.mjs",
"collect:shop-vip": "node scripts/collect-prices.mjs --all --kind shopApi --shop-scheduler --shop-scheduler-group vip_15m --post",
"rebalance:source-shards": "node scripts/rebalance-source-shards.mjs",
Expand All @@ -49,6 +49,9 @@
"test:channel-submissions": "node scripts/run-ts-test.mjs scripts/test-channel-submissions.ts",
"test:auth-security": "node scripts/run-ts-test.mjs scripts/test-auth-security.ts",
"test:product-offer-pagination": "node scripts/run-ts-test.mjs scripts/test-product-offer-pagination.ts && node scripts/run-ts-test.mjs scripts/test-product-offer-filters.ts",
"test:price-history": "node scripts/run-ts-test.mjs scripts/test-price-history.ts",
"test:price-history-db": "node scripts/test-price-history-db.mjs",
"test:price-history-api": "node scripts/test-price-history-api.mjs",
"collect:worker": "node scripts/collect-worker.mjs",
"import:api-models": "node scripts/import-api-models.mjs",
"check:p2": "node scripts/p2-closeout-check.mjs",
Expand All @@ -71,6 +74,7 @@
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.105.3",
"html-to-image": "^1.11.13",
"lightweight-charts": "^5.2.0",
"lucide-react": "^1.14.0",
"next": "16.2.9",
"next-mdx-remote": "^6.0.0",
Expand Down
107 changes: 107 additions & 0 deletions scripts/test-price-history-api.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env node

import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const port = 32000 + (process.pid % 10000);
const baseUrl = `http://127.0.0.1:${port}`;
const node = path.join(repoRoot, "node_modules", "node", "bin", "node");
const next = path.join(repoRoot, "node_modules", "next", "dist", "bin", "next");
const log = [];
const server = spawn(node, [next, "dev", "--webpack", "--hostname", "127.0.0.1", "--port", String(port)], {
cwd: repoRoot,
env: {
...process.env,
NEXT_PUBLIC_SUPABASE_URL: "",
SUPABASE_SERVICE_ROLE_KEY: "",
},
stdio: ["ignore", "pipe", "pipe"],
});

server.stdout.on("data", (chunk) => rememberLog(chunk));
server.stderr.on("data", (chunk) => rememberLog(chunk));

try {
await waitForServer();

const oneHour = await getJson("/api/products/chatgpt-plus/price-candles?interval=1h&limit=720", 200);
assert(oneHour.body.productId === "chatgpt-plus", "detail API did not normalize the product ID");
assert(oneHour.body.interval === "1h", "detail API did not return 1h");
assert(Array.isArray(oneHour.body.candles) && oneHour.body.candles.length === 0, "unconfigured detail API did not return empty history");
assert(oneHour.body.current?.price === null, "unconfigured detail API returned a synthetic current price");
assertCacheHeaders(oneHour.response);

const oneDay = await getJson("/api/products/chatgpt-plus/price-candles?interval=1d&limit=365&before=2026-07-20T00%3A00%3A00Z", 200);
assert(oneDay.body.interval === "1d", "detail API did not return 1d");
assertCacheHeaders(oneDay.response);

await getJson("/api/products/chatgpt-plus/price-candles?interval=4h", 400);
await getJson("/api/products/chatgpt-plus/price-candles?interval=1h&limit=721", 400);
await getJson("/api/products/chatgpt-plus/price-candles?interval=1d&before=2026-07-20", 400);
await getJson("/api/products/not-a-product/price-candles?interval=1d", 404);

const summaries1h = await getJson("/api/price-chart-summaries?interval=1h&points=24&platform=ChatGPT", 200);
assert(summaries1h.body.interval === "1h" && summaries1h.body.points === 24, "summary API did not return 1h/24");
assert(Array.isArray(summaries1h.body.products) && summaries1h.body.products.length > 1, "summary API did not return a product batch");
assert(summaries1h.body.products.every((item) => item.candles.length === 0), "unconfigured summary API returned synthetic candles");
assertCacheHeaders(summaries1h.response);

const summaries1d = await getJson("/api/price-chart-summaries?interval=1d&points=30&productType=%E8%AE%A2%E9%98%85%2F%E4%BC%9A%E5%91%98", 200);
assert(summaries1d.body.interval === "1d" && summaries1d.body.points === 30, "summary API did not return 1d/30");
assert(!containsInternalField(summaries1d.body), "public price history API exposed internal quote fields");
await getJson("/api/price-chart-summaries?interval=1d&points=25", 400);

console.log("product price history API test passed");
} catch (error) {
const suffix = log.length ? `\nNext dev output:\n${log.join("")}` : "";
throw new Error(`${error instanceof Error ? error.message : String(error)}${suffix}`);
} finally {
server.kill("SIGTERM");
await Promise.race([
new Promise((resolve) => server.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
if (server.exitCode === null) server.kill("SIGKILL");
}

async function waitForServer() {
for (let attempt = 0; attempt < 120; attempt += 1) {
if (server.exitCode !== null) throw new Error(`Next dev exited with ${server.exitCode}`);
try {
const response = await fetch(`${baseUrl}/api/price-chart-summaries?interval=1d&points=30`);
if (response.status === 200) return;
} catch {
// The dev server is still compiling.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error("Next dev did not become ready within 60 seconds");
}

async function getJson(pathname, expectedStatus) {
const response = await fetch(`${baseUrl}${pathname}`);
const body = await response.json().catch(() => null);
assert(response.status === expectedStatus, `${pathname} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(body)}`);
return { response, body };
}

function assertCacheHeaders(response) {
assert(response.headers.get("cdn-cache-control") === "public, s-maxage=300", "CDN cache TTL is not 300 seconds");
assert(response.headers.get("cloudflare-cdn-cache-control") === "public, s-maxage=300", "Cloudflare cache TTL is not 300 seconds");
}

function containsInternalField(value) {
const text = JSON.stringify(value);
return /offer_id|source_id|confirmation|consecutive_valid/i.test(text);
}

function rememberLog(chunk) {
log.push(String(chunk));
while (log.join("").length > 12000) log.shift();
}

function assert(condition, message) {
if (!condition) throw new Error(message);
}
Loading