diff --git a/.github/workflows/collect-product-price-history.yml b/.github/workflows/collect-product-price-history.yml
new file mode 100644
index 0000000..cf304b2
--- /dev/null
+++ b/.github/workflows/collect-product-price-history.yml
@@ -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
diff --git a/.github/workflows/maintenance-retention.yml b/.github/workflows/maintenance-retention.yml
index 5d86432..41599e0 100644
--- a/.github/workflows/maintenance-retention.yml
+++ b/.github/workflows/maintenance-retention.yml
@@ -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:
diff --git a/package-lock.json b/package-lock.json
index 2266243..36347ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,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",
@@ -7350,6 +7351,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
+ "node_modules/fancy-canvas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
+ "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -9285,6 +9292,15 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lightweight-charts": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz",
+ "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "fancy-canvas": "2.1.0"
+ }
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
diff --git a/package.json b/package.json
index c835ab7..96a6513 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -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",
@@ -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",
diff --git a/scripts/test-price-history-api.mjs b/scripts/test-price-history-api.mjs
new file mode 100644
index 0000000..f26f5fc
--- /dev/null
+++ b/scripts/test-price-history-api.mjs
@@ -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);
+}
diff --git a/scripts/test-price-history-db.mjs b/scripts/test-price-history-db.mjs
new file mode 100644
index 0000000..322dc06
--- /dev/null
+++ b/scripts/test-price-history-db.mjs
@@ -0,0 +1,360 @@
+#!/usr/bin/env node
+
+import crypto from "node:crypto";
+import { readFileSync } from "node:fs";
+import { spawnSync } 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 schema = readFileSync(path.join(repoRoot, "supabase", "schema.sql"), "utf8");
+const docker = commandPath("docker");
+assert(docker, "未找到 docker,无法执行价格历史 PostgreSQL 集成测试。");
+
+const image = process.env.PRICEAI_TEST_POSTGRES_IMAGE || "postgres:18-alpine";
+const container = `priceai-price-history-${process.pid}-${Date.now()}`;
+const password = crypto.randomBytes(18).toString("hex");
+
+try {
+ run(docker, ["run", "--rm", "--detach", "--name", container, "--env", `POSTGRES_PASSWORD=${password}`, image]);
+ waitForPostgres(docker, container);
+ runPsql(`
+ create role anon nologin;
+ create role authenticated nologin;
+ create role service_role nologin;
+ create schema auth;
+ create function auth.uid() returns uuid language sql stable as $$ select null::uuid $$;
+ `);
+ runPsql(schema);
+ runPsql(testSql());
+ console.log("product price history database test passed");
+} finally {
+ spawnSync(docker, ["rm", "--force", container], { stdio: "ignore" });
+}
+
+function runPsql(sql) {
+ const result = spawnSync(
+ docker,
+ ["exec", "-i", container, "psql", "-X", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", "postgres"],
+ { input: sql, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, stdio: ["pipe", "ignore", "pipe"] },
+ );
+ if (result.status !== 0) throw new Error(result.stderr || "价格历史 PostgreSQL 集成测试失败。");
+}
+
+function waitForPostgres(command, name) {
+ let consecutiveReady = 0;
+ for (let attempt = 0; attempt < 60; attempt += 1) {
+ const probe = spawnSync(command, ["exec", name, "pg_isready", "-U", "postgres"], { stdio: "ignore" });
+ consecutiveReady = probe.status === 0 ? consecutiveReady + 1 : 0;
+ if (consecutiveReady >= 2) return;
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
+ }
+ throw new Error("隔离 PostgreSQL 在 30 秒内没有就绪。");
+}
+
+function run(command, args) {
+ const result = spawnSync(command, args, { encoding: "utf8" });
+ if (result.status !== 0) throw new Error(result.stderr || `${command} 执行失败。`);
+}
+
+function commandPath(name) {
+ const result = spawnSync("which", [name], { encoding: "utf8" });
+ return result.status === 0 ? result.stdout.trim() : null;
+}
+
+function assert(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+function testSql() {
+ return String.raw`
+set timezone = 'UTC';
+
+insert into canonical_products (id, slug, display_name, platform, product_type, spec, summary)
+values
+ ('p1', 'p1', 'Product One', 'ChatGPT', '订阅/会员', '', ''),
+ ('p2', 'p2', 'Product Two', 'Claude', '订阅/会员', '', ''),
+ ('p3', 'p3', 'Product Three', 'Gemini', '成品账号', '', '');
+
+insert into sources (id, name, entry_url, enabled)
+values ('s1', 'Source One', 'https://example.com', true);
+
+insert into raw_offers (
+ id, source_id, source_name, source_title, price, currency, status, source_status,
+ effective_status, freshness_status, url, tags, stock_count, min_order_quantity,
+ hidden, canonical_product_id, captured_at, last_seen_at, verified_at, expires_at
+)
+values
+ ('valid-low', 's1', 'Source One', '独享账号', 10, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/low', '{}', 9, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('valid-high', 's1', 'Source One', '独享账号', 12, 'CNY', 'low_stock', 'low_stock', 'available', 'fresh', 'https://example.com/high', '{}', null, null, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('valid-p2', 's1', 'Source One', '独享账号', 33, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/p2', '{}', 2, 1, false, 'p2', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-hidden', 's1', 'Source One', '独享账号', 1, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/hidden', '{}', 1, 1, true, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-zero', 's1', 'Source One', '独享账号', 0, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/zero', '{}', 1, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-currency', 's1', 'Source One', '独享账号', 1, 'USD', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/usd', '{}', 1, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-url', 's1', 'Source One', '独享账号', 1, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', '', '{}', 1, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-status', 's1', 'Source One', '独享账号', 1, 'CNY', 'out_of_stock', 'out_of_stock', 'unavailable', 'fresh', 'https://example.com/status', '{}', 1, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-stock', 's1', 'Source One', '独享账号', 1, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/stock', '{}', 0, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-minimum', 's1', 'Source One', '独享账号', 1, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/minimum', '{}', 1, 2, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-shared', 's1', 'Source One', '多人共享拼车', 1, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/shared', '{}', 1, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-web', 's1', 'Source One', '仅限网页号', 1, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/web', '{}', 1, 1, false, 'p1', now(), now(), now(), now() + interval '7 days'),
+ ('invalid-mirror', 's1', 'Source One', '国内镜像站', 1, 'CNY', 'in_stock', 'in_stock', 'available', 'fresh', 'https://example.com/mirror', '{}', 1, 1, false, 'p1', now(), now(), now(), now() + interval '7 days');
+
+create function test_confirm(p_offer_id text, p_confirmed_at timestamptz)
+returns void
+language sql
+as $$
+ insert into raw_offer_confirmations (
+ raw_offer_id, source_id, canonical_product_id, confirmed_at, captured_at,
+ last_seen_at, verified_at, expires_at, source_status, effective_status,
+ freshness_status, source_priority, confidence, price, stock_count, updated_at
+ )
+ select
+ offers.id, offers.source_id, offers.canonical_product_id, p_confirmed_at,
+ p_confirmed_at, p_confirmed_at, p_confirmed_at, now() + interval '7 days',
+ offers.source_status, offers.effective_status, offers.freshness_status,
+ offers.source_priority, offers.confidence, offers.price, offers.stock_count,
+ p_confirmed_at
+ from raw_offers offers
+ where offers.id = p_offer_id
+ on conflict (raw_offer_id) do update set
+ source_id = excluded.source_id,
+ canonical_product_id = excluded.canonical_product_id,
+ confirmed_at = excluded.confirmed_at,
+ captured_at = excluded.captured_at,
+ last_seen_at = excluded.last_seen_at,
+ verified_at = excluded.verified_at,
+ expires_at = excluded.expires_at,
+ source_status = excluded.source_status,
+ effective_status = excluded.effective_status,
+ freshness_status = excluded.freshness_status,
+ source_priority = excluded.source_priority,
+ confidence = excluded.confidence,
+ price = excluded.price,
+ stock_count = excluded.stock_count,
+ updated_at = excluded.updated_at;
+$$;
+
+select test_confirm(id, now()) from raw_offers;
+
+do $$
+begin
+ if (select consecutive_valid_confirmations from raw_offer_confirmations where raw_offer_id = 'valid-low') <> 1 then
+ raise exception 'first valid confirmation was not recorded as one';
+ end if;
+ if exists (select 1 from product_price_eligible_offers) then
+ raise exception 'an offer became eligible before its second confirmation';
+ end if;
+end;
+$$;
+
+select test_confirm(id, now() + interval '1 minute') from raw_offers;
+
+update raw_offer_confirmations
+set
+ confirmed_at = confirmed_at - interval '1 hour',
+ effective_status = 'unavailable'
+where raw_offer_id = 'valid-low';
+
+do $$
+begin
+ if (select count(*) from product_price_eligible_offers) <> 3 then
+ raise exception 'eligible offer filters or two-confirmation rule failed';
+ end if;
+ if exists (
+ select 1 from raw_offer_confirmations
+ where raw_offer_id like 'invalid-%' and consecutive_valid_confirmations <> 0
+ ) then
+ raise exception 'invalid offers retained a confirmation streak';
+ end if;
+ if (select consecutive_valid_confirmations from raw_offer_confirmations where raw_offer_id = 'valid-low') <> 2 then
+ raise exception 'an out-of-order confirmation overwrote the latest streak';
+ end if;
+end;
+$$;
+
+update raw_offers
+set source_status = 'out_of_stock'
+where id = 'valid-high';
+
+do $$
+begin
+ if (select consecutive_valid_confirmations from raw_offer_confirmations where raw_offer_id = 'valid-high') <> 0 then
+ raise exception 'source-status-only unavailability did not reset the confirmation streak';
+ end if;
+end;
+$$;
+
+update raw_offers
+set source_status = 'low_stock'
+where id = 'valid-high';
+select test_confirm('valid-high', now() + interval '2 minutes');
+
+do $$
+begin
+ if (select consecutive_valid_confirmations from raw_offer_confirmations where raw_offer_id = 'valid-high') <> 1 then
+ raise exception 'a restored offer skipped its first confirmation';
+ end if;
+end;
+$$;
+
+select test_confirm('valid-high', now() + interval '3 minutes');
+
+do $$
+begin
+ if (select consecutive_valid_confirmations from raw_offer_confirmations where raw_offer_id = 'valid-high') <> 2 then
+ raise exception 'a restored offer did not become eligible after two confirmations';
+ end if;
+end;
+$$;
+
+select record_product_price_samples(null, '2026-07-20T15:05:00Z');
+
+do $$
+begin
+ if (select price from product_price_samples where product_id = 'p1') <> 10 then
+ raise exception 'lowest eligible price was not sampled';
+ end if;
+ if (select price from product_price_samples where product_id = 'p2') <> 33 then
+ raise exception 'product isolation failed';
+ end if;
+ if exists (select 1 from product_price_samples where product_id = 'p3') then
+ raise exception 'product without an eligible price produced a sample';
+ end if;
+end;
+$$;
+
+update raw_offers set price = 11 where id = 'valid-low';
+select test_confirm('valid-low', now() + interval '2 minutes');
+select test_confirm('valid-low', now() + interval '3 minutes');
+select record_product_price_samples(array['p1'], '2026-07-20T15:20:00Z');
+
+update raw_offers set price = 9 where id = 'valid-low';
+select test_confirm('valid-low', now() + interval '4 minutes');
+select test_confirm('valid-low', now() + interval '5 minutes');
+select record_product_price_samples(array['p1'], '2026-07-20T15:35:00Z');
+
+update raw_offers set price = 12 where id = 'valid-low';
+select test_confirm('valid-low', now() + interval '6 minutes');
+select test_confirm('valid-low', now() + interval '7 minutes');
+select record_product_price_samples(array['p1'], '2026-07-20T15:50:00Z');
+
+update raw_offers set price = 8 where id = 'valid-low';
+select test_confirm('valid-low', now() + interval '8 minutes');
+select test_confirm('valid-low', now() + interval '9 minutes');
+select record_product_price_samples(array['p1'], '2026-07-20T15:48:00Z');
+select record_product_price_samples(array['p1'], '2026-07-20T15:50:00Z');
+
+do $$
+declare
+ candle product_price_candles%rowtype;
+begin
+ select * into candle
+ from product_price_candles
+ where product_id = 'p1' and candle_interval = '1h' and bucket_start = '2026-07-20T15:00:00Z';
+ if candle.open_price <> 10 or candle.high_price <> 12 or candle.low_price <> 9 or candle.close_price <> 12 or candle.sample_count <> 4 then
+ raise exception 'hourly OHLC, ordering, or idempotency failed: %', row_to_json(candle);
+ end if;
+ if (select price from product_price_samples where product_id = 'p1' and bucket_start = '2026-07-20T15:45:00Z') <> 12 then
+ raise exception 'out-of-order sampling overwrote a newer sample';
+ end if;
+ if not exists (
+ select 1 from product_price_candles
+ where product_id = 'p1' and candle_interval = '1d' and bucket_start = '2026-07-19T16:00:00Z'
+ ) then
+ raise exception 'Asia/Shanghai daily bucket was not created';
+ end if;
+end;
+$$;
+
+select record_product_price_samples(array['p1'], '2026-07-20T16:05:00Z');
+select record_product_price_samples(array['p1'], '2026-07-20T18:05:00Z');
+
+do $$
+begin
+ if (select count(*) from product_price_candles where product_id = 'p1' and candle_interval = '1d') <> 2 then
+ raise exception 'Beijing day boundary did not create two daily candles';
+ end if;
+ if exists (
+ select 1 from product_price_candles
+ where product_id = 'p1' and candle_interval = '1h' and bucket_start = '2026-07-20T17:00:00Z'
+ ) then
+ raise exception 'a missing observation interval was filled artificially';
+ end if;
+ if exists (
+ select 1 from product_price_candles
+ where product_id = 'p1' and candle_interval = '1h'
+ and sample_count = 1 and (open_price <> high_price or high_price <> low_price or low_price <> close_price)
+ ) then
+ raise exception 'single-sample candle was not flat';
+ end if;
+end;
+$$;
+
+update raw_offers set effective_status = 'unavailable' where id in ('valid-low', 'valid-high');
+
+do $$
+begin
+ if exists (select 1 from list_product_price_current(array['p1'])) then
+ raise exception 'current quote remained after all eligible offers disappeared';
+ end if;
+ if not exists (select 1 from list_public_product_price_candles(array['p1'], '1h', 24, null)) then
+ raise exception 'historical candles disappeared with the current quote';
+ end if;
+end;
+$$;
+
+insert into product_price_samples (
+ product_id, bucket_start, observed_at, price, currency, eligible_offer_count
+)
+values
+ ('p2', '2024-01-01T00:00:00Z', '2024-01-01T00:05:00Z', 20, 'CNY', 1),
+ ('p3', '2024-02-01T00:00:00Z', '2024-02-01T00:05:00Z', 30, 'CNY', 1);
+
+insert into product_price_candles (
+ product_id, candle_interval, bucket_start, bucket_end, open_price, high_price,
+ low_price, close_price, currency, sample_count, eligible_offer_count,
+ first_sample_at, last_sample_at
+)
+values
+ ('p2', '1h', '2024-01-01T00:00:00Z', '2024-01-01T01:00:00Z', 20, 20, 20, 20, 'CNY', 1, 1, '2024-01-01T00:05:00Z', '2024-01-01T00:05:00Z'),
+ ('p2', '1d', '2023-12-31T16:00:00Z', '2024-01-01T16:00:00Z', 20, 20, 20, 20, 'CNY', 1, 1, '2024-01-01T00:05:00Z', '2024-01-01T00:05:00Z');
+
+do $$
+declare
+ result jsonb;
+begin
+ result := prune_product_price_history(100, true);
+ if (result ->> 'sampleCandidates')::integer <> 1 then
+ raise exception 'dry-run did not enforce aggregate coverage: %', result;
+ end if;
+ if not exists (select 1 from product_price_samples where product_id = 'p2' and bucket_start = '2024-01-01T00:00:00Z') then
+ raise exception 'dry-run deleted a sample';
+ end if;
+
+ result := prune_product_price_history(100, false);
+ if (result ->> 'deletedSamples')::integer <> 1 then
+ raise exception 'bounded retention did not delete the covered sample: %', result;
+ end if;
+ if not exists (select 1 from product_price_samples where product_id = 'p3' and bucket_start = '2024-02-01T00:00:00Z') then
+ raise exception 'retention deleted a sample without hourly and daily coverage';
+ end if;
+ if not exists (select 1 from product_price_candles where product_id = 'p2' and candle_interval = '1d') then
+ raise exception 'daily retention was applied unexpectedly';
+ end if;
+end;
+$$;
+
+do $$
+begin
+ if has_table_privilege('anon', 'product_price_samples', 'select')
+ or has_table_privilege('authenticated', 'product_price_candles', 'select')
+ then
+ raise exception 'internal price history tables are publicly readable';
+ end if;
+ if has_function_privilege('anon', 'record_product_price_samples(text[],timestamptz)', 'execute') then
+ raise exception 'sampling RPC is publicly executable';
+ end if;
+end;
+$$;
+`;
+}
diff --git a/scripts/test-price-history.ts b/scripts/test-price-history.ts
new file mode 100644
index 0000000..8e25384
--- /dev/null
+++ b/scripts/test-price-history.ts
@@ -0,0 +1,96 @@
+import {
+ compactProductPriceCandle,
+ groupProductPriceCandleRows,
+ parseExclusiveBefore,
+ parsePriceChartPoints,
+ parsePriceHistoryInterval,
+ parsePriceHistoryLimit,
+ productPriceChange,
+} from "../src/lib/price-history.js";
+
+assertEqual(parsePriceHistoryInterval("1h"), "1h");
+assertEqual(parsePriceHistoryInterval("1d"), "1d");
+assertEqual(parsePriceHistoryInterval("4h"), null);
+assertEqual(parsePriceHistoryLimit("1h", null), 168);
+assertEqual(parsePriceHistoryLimit("1d", undefined), 90);
+assertEqual(parsePriceHistoryLimit("1h", "720"), 720);
+assertEqual(parsePriceHistoryLimit("1h", "721"), null);
+assertEqual(parsePriceHistoryLimit("1d", "0"), null);
+assertEqual(parsePriceHistoryLimit("1d", "1.5"), null);
+assertEqual(parsePriceChartPoints("1h", null), 24);
+assertEqual(parsePriceChartPoints("1d", null), 30);
+assertEqual(parsePriceChartPoints("1d", "24"), 24);
+assertEqual(parsePriceChartPoints("1d", "25"), null);
+assertEqual(parseExclusiveBefore("2026-07-20T12:34:56+08:00"), "2026-07-20T04:34:56.000Z");
+assertEqual(parseExclusiveBefore("2026-07-20"), null);
+
+const grouped = groupProductPriceCandleRows(["a", "b"], [
+ row("a", "2026-07-20T00:00:00.000Z", 10, 12, 9, 11, 2),
+ row("a", "2026-07-20T01:00:00.000Z", 11, 13, 10, 12, 3),
+ row("a", "2026-07-20T02:00:00.000Z", 12, 14, 11, 13, 4),
+ {
+ ...row("b", "invalid", 1, 2, 0.5, 1.5, 1),
+ period_start: "invalid",
+ },
+]);
+
+assertEqual(grouped.a.length, 3);
+assertEqual(grouped.a[0]?.time, "2026-07-20T00:00:00.000Z");
+assertEqual(grouped.b.length, 0);
+assertDeepEqual(productPriceChange([]), { change: null, changePercent: null });
+assertDeepEqual(productPriceChange(grouped.a.slice(0, 1)), { change: null, changePercent: null });
+assertEqual(productPriceChange(grouped.a).change, 1);
+assertApprox(productPriceChange(grouped.a).changePercent, 100 / 12);
+assertDeepEqual(compactProductPriceCandle(grouped.a[2]!), [
+ "2026-07-20T02:00:00.000Z",
+ 12,
+ 14,
+ 11,
+ 13,
+ 4,
+]);
+
+console.log("price history test passed");
+
+function row(
+ productId: string,
+ time: string,
+ open: number,
+ high: number,
+ low: number,
+ close: number,
+ sampleCount: number,
+) {
+ return {
+ product_id: productId,
+ period_start: time,
+ open_price: open,
+ high_price: high,
+ low_price: low,
+ close_price: close,
+ sample_count: sampleCount,
+ eligible_offer_count: 2,
+ first_sample_at: time,
+ last_sample_at: time,
+ };
+}
+
+function assertEqual(actual: unknown, expected: unknown) {
+ if (actual !== expected) {
+ throw new Error(`Expected ${JSON.stringify(actual)} to equal ${JSON.stringify(expected)}.`);
+ }
+}
+
+function assertDeepEqual(actual: unknown, expected: unknown) {
+ const actualText = JSON.stringify(actual);
+ const expectedText = JSON.stringify(expected);
+ if (actualText !== expectedText) {
+ throw new Error(`Expected ${actualText} to equal ${expectedText}.`);
+ }
+}
+
+function assertApprox(actual: number | null, expected: number) {
+ if (actual === null || Math.abs(actual - expected) > 0.000001) {
+ throw new Error(`Expected ${JSON.stringify(actual)} to approximately equal ${expected}.`);
+ }
+}
diff --git a/scripts/verify-supabase-recovery-baseline.mjs b/scripts/verify-supabase-recovery-baseline.mjs
index 9b779f0..36a8d7a 100644
--- a/scripts/verify-supabase-recovery-baseline.mjs
+++ b/scripts/verify-supabase-recovery-baseline.mjs
@@ -32,6 +32,9 @@ assert(
for (const requiredSql of [
"create table if not exists canonical_products",
"create table if not exists raw_offers",
+ "create table if not exists product_price_samples",
+ "create table if not exists product_price_candles",
+ "create or replace function record_product_price_samples",
"create table if not exists public_user_profiles",
"create or replace function public.claim_runtime_lease",
"create or replace function public.consume_feedback_evidence_upload_quota",
diff --git a/src/app/api/cron/product-price-history/route.ts b/src/app/api/cron/product-price-history/route.ts
new file mode 100644
index 0000000..6fa663f
--- /dev/null
+++ b/src/app/api/cron/product-price-history/route.ts
@@ -0,0 +1,58 @@
+import { logApiError, safeApiErrorMessage } from "@/lib/api-errors";
+import { authorizeCronRequest, cronMethodNotAllowed } from "@/lib/cron-auth";
+import { pruneProductPriceHistory, recordProductPriceSamples } from "@/lib/price-history-db";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 120;
+
+export function GET() {
+ return cronMethodNotAllowed("执行标准商品价格历史维护");
+}
+
+export async function POST(request: Request) {
+ const authError = authorizeCronRequest(request, "执行标准商品价格历史维护");
+ if (authError) return authError;
+
+ const url = new URL(request.url);
+ const action = url.searchParams.get("action");
+ if (action !== "sample" && action !== "prune") {
+ return Response.json({ ok: false, message: "action 仅支持 sample 或 prune。" }, { status: 400 });
+ }
+
+ const startedAt = new Date().toISOString();
+ const batchSize = boundedBatchSize(url.searchParams.get("batch"));
+ if (action === "prune" && batchSize === null) {
+ return Response.json({ ok: false, message: "batch 必须是 100 到 20000 之间的整数。" }, { status: 400 });
+ }
+
+ try {
+ const dryRun = url.searchParams.get("apply") !== "1";
+ const result = action === "sample"
+ ? await recordProductPriceSamples(startedAt)
+ : await pruneProductPriceHistory({ batchSize: batchSize || 5000, dryRun });
+ return Response.json({
+ ok: true,
+ action,
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ ...(action === "prune" ? { dryRun, batchSize } : {}),
+ result,
+ }, { headers: { "Cache-Control": "no-store" } });
+ } catch (error) {
+ logApiError(`cron product price history ${action}`, error);
+ return Response.json({
+ ok: false,
+ action,
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ message: safeApiErrorMessage(error, "标准商品价格历史维护失败。"),
+ }, { status: 500, headers: { "Cache-Control": "no-store" } });
+ }
+}
+
+function boundedBatchSize(value: string | null): number | null {
+ if (!value) return 5000;
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed >= 100 && parsed <= 20000 ? parsed : null;
+}
diff --git a/src/app/api/price-chart-summaries/route.ts b/src/app/api/price-chart-summaries/route.ts
new file mode 100644
index 0000000..6b6aea6
--- /dev/null
+++ b/src/app/api/price-chart-summaries/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { publicPriceApiErrorResponse } from "@/lib/api-errors";
+import { priceDataCacheHeaders } from "@/lib/cache-headers";
+import { cacheSearchParams } from "@/lib/cloudflare-cache-key";
+import { withCloudflarePublicCache } from "@/lib/cloudflare-edge-cache";
+import { parsePriceChartPoints, parsePriceHistoryInterval } from "@/lib/price-history";
+import { getProductPriceChartSummaries } from "@/lib/price-history-db";
+import { PRICE_DATA_EDGE_SECONDS } from "@/lib/public-cache-policy";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+export async function GET(request: NextRequest) {
+ const interval = parsePriceHistoryInterval(request.nextUrl.searchParams.get("interval") || "1d");
+ if (!interval) return NextResponse.json({ message: "K 线周期仅支持 1h 或 1d。" }, { status: 400 });
+ const points = parsePriceChartPoints(interval, request.nextUrl.searchParams.get("points"));
+ if (!points) return NextResponse.json({ message: "points 仅支持 24 或 30。" }, { status: 400 });
+
+ const platform = normalizedFilter(request.nextUrl.searchParams.get("platform"));
+ const productType = normalizedFilter(request.nextUrl.searchParams.get("productType"));
+ if (platform === null || productType === null) {
+ return NextResponse.json({ message: "筛选参数不能超过 80 个字符。" }, { status: 400 });
+ }
+
+ return withCloudflarePublicCache(request, {
+ namespace: "price-chart-summaries-v1",
+ ttlSeconds: PRICE_DATA_EDGE_SECONDS,
+ cacheKeySearchParams: cacheSearchParams({ interval, points, platform, productType }),
+ load: async () => {
+ try {
+ const result = await getProductPriceChartSummaries({ interval, points, platform, productType });
+ return NextResponse.json(result, { headers: priceDataCacheHeaders() });
+ } catch (error) {
+ return publicPriceApiErrorResponse("public price chart summaries API", error);
+ }
+ },
+ });
+}
+
+function normalizedFilter(value: string | null): string | null | undefined {
+ const normalized = value?.trim();
+ if (!normalized || normalized === "全部") return undefined;
+ return normalized.length <= 80 ? normalized : null;
+}
diff --git a/src/app/api/products/[id]/price-candles/route.ts b/src/app/api/products/[id]/price-candles/route.ts
new file mode 100644
index 0000000..c7d6fe2
--- /dev/null
+++ b/src/app/api/products/[id]/price-candles/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from "next/server";
+import { publicPriceApiErrorResponse } from "@/lib/api-errors";
+import { priceDataCacheHeaders } from "@/lib/cache-headers";
+import { cacheSearchParams } from "@/lib/cloudflare-cache-key";
+import { withCloudflarePublicCache } from "@/lib/cloudflare-edge-cache";
+import {
+ parseExclusiveBefore,
+ parsePriceHistoryInterval,
+ parsePriceHistoryLimit,
+} from "@/lib/price-history";
+import { getProductPriceCandles, resolvePriceHistoryProduct } from "@/lib/price-history-db";
+import { PRICE_DATA_EDGE_SECONDS } from "@/lib/public-cache-policy";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const product = resolvePriceHistoryProduct(id);
+ if (!product) return NextResponse.json({ message: "标准商品不存在。" }, { status: 404 });
+
+ const interval = parsePriceHistoryInterval(request.nextUrl.searchParams.get("interval") || "1d");
+ if (!interval) return NextResponse.json({ message: "K 线周期仅支持 1h 或 1d。" }, { status: 400 });
+
+ const limit = parsePriceHistoryLimit(interval, request.nextUrl.searchParams.get("limit"));
+ if (limit === null) {
+ return NextResponse.json(
+ { message: `limit 必须是 1 到 ${interval === "1h" ? 720 : 365} 之间的整数。` },
+ { status: 400 },
+ );
+ }
+ const before = parseExclusiveBefore(request.nextUrl.searchParams.get("before"));
+ if (before === null) return NextResponse.json({ message: "before 必须是带时区的 ISO 时间。" }, { status: 400 });
+
+ return withCloudflarePublicCache(request, {
+ namespace: "product-price-candles-v1",
+ ttlSeconds: PRICE_DATA_EDGE_SECONDS,
+ cacheKeySearchParams: cacheSearchParams({ interval, limit, before }),
+ load: async () => {
+ try {
+ const result = await getProductPriceCandles({
+ productId: product.id,
+ interval,
+ limit,
+ before,
+ });
+ return NextResponse.json(result, { headers: priceDataCacheHeaders() });
+ } catch (error) {
+ return publicPriceApiErrorResponse("public product price candles API", error);
+ }
+ },
+ });
+}
diff --git a/src/app/products/[id]/page.tsx b/src/app/products/[id]/page.tsx
index 81e0c17..0858b02 100644
--- a/src/app/products/[id]/page.tsx
+++ b/src/app/products/[id]/page.tsx
@@ -7,6 +7,7 @@ import { BrandIcon } from "@/components/BrandIcon";
import { JsonLd } from "@/components/JsonLd";
import { ProductDetailHeader, ProductReturnLink } from "@/components/ProductDetailHeader";
import { ProductOffersPanel } from "@/components/ProductOffersPanel";
+import { ProductPriceHistoryChart } from "@/components/ProductPriceHistoryChart";
import { publicCatalogProducts } from "@/lib/catalog";
import { getPublicProductSummary, listPublicProductOffers } from "@/lib/data";
import {
@@ -133,6 +134,8 @@ export default async function ProductDetail({
|
+ |
+
+
+ {(["1h", "1d"] as const).map((value) => (
+
+ ))}
+
+ );
+}
+
function WarrantyLowestPrice({
product,
returnQuery,
diff --git a/src/components/ProductPriceHistoryChart.tsx b/src/components/ProductPriceHistoryChart.tsx
new file mode 100644
index 0000000..c710d8a
--- /dev/null
+++ b/src/components/ProductPriceHistoryChart.tsx
@@ -0,0 +1,451 @@
+"use client";
+
+import { RefreshCw, RotateCcw } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type {
+ CandlestickData,
+ IChartApi,
+ ISeriesApi,
+ MouseEventParams,
+ Time,
+ UTCTimestamp,
+} from "lightweight-charts";
+import type {
+ PriceHistoryInterval,
+ ProductPriceCandle,
+ ProductPriceCandleResponse,
+} from "@/lib/price-history";
+import { formatCurrency, formatRelativeTime } from "@/lib/utils";
+
+const INTERVAL_LIMITS: Record
+
+
+
+
+ + 最低可买价走势 +++ {productName} · 最低起购量为 1 或无批量限制 · 每根 K 线代表 {interval === "1h" ? "1 小时" : "1 天"} + +
+
+
+
+ {loading && !response ? (
+
+
+
+
+
+ + K 线基于符合条件的最低公开报价生成,仅供价格观察,不代表实际成交价格。 + +
+
+ );
+}
+
+function CandlestickCanvas({
+ candles,
+ interval,
+ resetVersion,
+ onActiveCandleChange,
+}: {
+ candles: ProductPriceCandle[];
+ interval: PriceHistoryInterval;
+ resetVersion: number;
+ onActiveCandleChange: (candle: ProductPriceCandle | null) => void;
+}) {
+ const containerRef = useRef
+
+ 当前最低可买价 +{loading && !response ? "加载中" : priceText} +
+
+ );
+}
+
+function IntervalSwitch({
+ interval,
+ onChange,
+}: {
+ interval: PriceHistoryInterval;
+ onChange: (value: PriceHistoryInterval) => void;
+}) {
+ return (
+ {formatCandleTime(candle.time, interval)} ++ 开 {formatCompactPrice(candle.open)} · 高 {formatCompactPrice(candle.high)} · 低 {formatCompactPrice(candle.low)} · 收 {formatCompactPrice(candle.close)} + +样本 {candle.sampleCount} · 较前期 {formatSignedChange(change.change, change.changePercent)} +
+ {(["1h", "1d"] as const).map((value) => (
+
+ ))}
+
+ );
+}
+
+function PriceFact({
+ label,
+ value,
+ tone = "default",
+}: {
+ label: string;
+ value: string;
+ tone?: "default" | "up" | "down" | "muted";
+}) {
+ const valueClass = { default: "text-[#202829]", up: "text-[#b43c34]", down: "text-[#2f7a4b]", muted: "text-[#7a8182]" }[tone];
+ return (
+
+
+ );
+}
+
+function ChartLoading() {
+ return (
+ {label}
+ {value}
+
+ {Array.from({ length: 22 }, (_, index) => (
+
+ ))}
+
+ );
+}
+
+function ChartMessage({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function candleChange(candles: ProductPriceCandle[], candle: ProductPriceCandle | null) {
+ if (!candle) return { change: null, changePercent: null };
+ const index = candles.findIndex((item) => item.time === candle.time);
+ const previousClose = index > 0 ? candles[index - 1]?.close : undefined;
+ if (!previousClose) return { change: null, changePercent: null };
+ const change = candle.close - previousClose;
+ return { change, changePercent: (change / previousClose) * 100 };
+}
+
+function changeTone(value: number | null): "up" | "down" | "muted" {
+ if (value === null || Math.abs(value) < 0.000001) return "muted";
+ return value > 0 ? "up" : "down";
+}
+
+function formatSignedChange(change: number | null, percent: number | null): string {
+ if (change === null || percent === null) return "-";
+ const sign = change > 0 ? "+" : "";
+ const direction = change > 0 ? "上涨" : change < 0 ? "下跌" : "持平";
+ return `${direction} ${sign}${formatNumber(change)} (${sign}${percent.toFixed(2)}%)`;
+}
+
+function formatCompactPrice(value: number): string {
+ return `¥${formatNumber(value)}`;
+}
+
+function formatNumber(value: number): string {
+ const digits = Math.abs(value) < 0.01 && value !== 0 ? 4 : 2;
+ return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: digits }).format(value);
+}
+
+function pricePrecision(candles: ProductPriceCandle[]): number {
+ const minimum = Math.min(...candles.flatMap((candle) => [candle.open, candle.high, candle.low, candle.close]));
+ return minimum < 0.01 ? 4 : 2;
+}
+
+function formatCandleTime(value: string, interval: PriceHistoryInterval): string {
+ return new Intl.DateTimeFormat("zh-CN", {
+ timeZone: "Asia/Shanghai",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ ...(interval === "1h" ? { hour: "2-digit", minute: "2-digit", hour12: false } : {}),
+ }).format(new Date(value));
+}
+
+function formatChartTime(time: Time, interval: PriceHistoryInterval, compact: boolean): string {
+ if (typeof time !== "number") {
+ if (typeof time === "string") return time;
+ return `${time.year}-${String(time.month).padStart(2, "0")}-${String(time.day).padStart(2, "0")}`;
+ }
+ const options: Intl.DateTimeFormatOptions = interval === "1h"
+ ? { timeZone: "Asia/Shanghai", month: compact ? undefined : "2-digit", day: compact ? undefined : "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }
+ : { timeZone: "Asia/Shanghai", year: compact ? undefined : "numeric", month: "2-digit", day: "2-digit" };
+ return new Intl.DateTimeFormat("zh-CN", options).format(new Date(time * 1000));
+}
diff --git a/src/lib/admin.ts b/src/lib/admin.ts
index f2c1538..9f8210f 100644
--- a/src/lib/admin.ts
+++ b/src/lib/admin.ts
@@ -584,9 +584,12 @@ export async function upsertRawOffer(input: OfferInput & { sourceId?: string | n
failureReason: existingManualHidden?.failureReason,
};
- const { error } = await supabase.from("raw_offers").upsert(toRawOfferRow(offer));
+ const row = toRawOfferRow(offer);
+ const { error } = await supabase.from("raw_offers").upsert(row);
if (error) throw error;
+ await upsertRawOfferConfirmations([row]);
+
return offer;
}
@@ -914,6 +917,7 @@ function rawOfferConfirmationRow(row: Record |