diff --git a/api/invoice-numbers.ts b/api/invoice-numbers.ts
new file mode 100644
index 0000000..ae1aad4
--- /dev/null
+++ b/api/invoice-numbers.ts
@@ -0,0 +1,39 @@
+import type { VercelRequest, VercelResponse } from '@vercel/node';
+// 副檔名不可省:Vercel 以 nodenext 解析 api/,省略會編譯失敗(見 api/tsconfig.json)
+import { parseInvoiceXml } from '../src/lib/invoice.js';
+
+// 統一發票開獎號碼代理:抓財政部公開 RSS(固定網址、免金鑰),
+// 解析成 JSON 回給前端。無使用者輸入,沒有 SSRF 面;不儲存任何資料。
+// 開獎兩個月一次,CDN 快取一小時綽綽有餘,也把流量壓到對得起官方伺服器。
+
+const SOURCE = 'https://invoice.etax.nat.gov.tw/invoice.xml';
+const TIMEOUT_MS = 8000;
+const UA = 'Mozilla/5.0 (compatible; TigletBot/1.0; +https://tiglet.vercel.app)';
+
+export default async function handler(_req: VercelRequest, res: VercelResponse) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ let xml: string;
+ try {
+ const response = await fetch(SOURCE, { signal: controller.signal, headers: { 'User-Agent': UA, Accept: 'application/xml,text/xml' } });
+ if (!response.ok) {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(502).json({ error: 'fetch-failed', status: response.status });
+ }
+ xml = await response.text();
+ } catch {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(502).json({ error: 'fetch-failed' });
+ } finally {
+ clearTimeout(timer);
+ }
+
+ const periods = parseInvoiceXml(xml);
+ if (periods.length === 0) {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(502).json({ error: 'parse-failed' });
+ }
+
+ res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
+ return res.status(200).json({ periods });
+}
diff --git a/e2e/invoice.spec.ts b/e2e/invoice.spec.ts
new file mode 100644
index 0000000..9b1b9fd
--- /dev/null
+++ b/e2e/invoice.spec.ts
@@ -0,0 +1,79 @@
+import { test, expect } from '@playwright/test';
+import { waitForIslands } from './helpers';
+
+const PERIODS = {
+ periods: [
+ {
+ period: '115年 03~04月',
+ link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11503',
+ special: '19531471',
+ grand: '85941329',
+ first: ['07225810', '20231230', '83518781'],
+ sixth: ['985'],
+ },
+ {
+ period: '115年 01~02月',
+ link: 'https://www.etax.nat.gov.tw/etw-main/ETW183W2_11501',
+ special: '87510041',
+ grand: '32220522',
+ first: ['21677046', '44662410', '31262513'],
+ sixth: [],
+ },
+ ],
+};
+
+function stub(page: import('@playwright/test').Page) {
+ return page.route('**/api/invoice-numbers', (route) =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PERIODS) })
+ );
+}
+
+test('載入開獎號碼,末 3 碼沒中直接判定、8 碼對中頭獎', async ({ page }) => {
+ await stub(page);
+ await page.goto('/tools/invoice-lottery');
+ await waitForIslands(page);
+
+ // 最新一期的號碼上桌
+ await expect(page.getByText('19531471')).toBeVisible();
+ await expect(page.getByText('07225810')).toBeVisible();
+
+ const input = page.getByPlaceholder('例如 046');
+ // 末 3 碼快篩:沒對中 → 明說「確定沒獎」
+ await input.fill('000');
+ await expect(page.getByRole('status')).toContainText('確定沒獎');
+
+ // 切到上一期,輸入完整 8 碼頭獎
+ await page.getByLabel('開獎期別').selectOption({ label: '115年 01~02月' });
+ await input.fill('21677046');
+ await expect(page.getByRole('status')).toContainText('中頭獎');
+ await expect(page.getByRole('status')).toContainText('200,000');
+});
+
+test('末 3 碼對中增開六獎直接確定 200 元;對中頭獎末 3 提示可升級', async ({ page }) => {
+ await stub(page);
+ await page.goto('/tools/invoice-lottery');
+ await waitForIslands(page);
+
+ const input = page.getByPlaceholder('例如 046');
+ await input.fill('985');
+ await expect(page.getByRole('status')).toContainText('增開六獎');
+
+ await input.fill('810'); // 頭獎 07225810 的末 3 碼
+ await expect(page.getByRole('status')).toContainText('六獎');
+ await expect(page.getByRole('status')).toContainText('繼續輸入完整 8 碼');
+});
+
+test('API 掛掉顯示錯誤並可重試', async ({ page }) => {
+ let calls = 0;
+ await page.route('**/api/invoice-numbers', (route) => {
+ calls += 1;
+ if (calls === 1) return route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"fetch-failed"}' });
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PERIODS) });
+ });
+ await page.goto('/tools/invoice-lottery');
+ await waitForIslands(page);
+
+ await expect(page.getByText('開獎號碼取得失敗,請稍後再試。')).toBeVisible();
+ await page.getByRole('button', { name: '重試' }).click();
+ await expect(page.getByText('19531471')).toBeVisible();
+});
diff --git a/public/og/en/invoice-lottery.png b/public/og/en/invoice-lottery.png
new file mode 100644
index 0000000..7576358
Binary files /dev/null and b/public/og/en/invoice-lottery.png differ
diff --git a/public/og/zh/invoice-lottery.png b/public/og/zh/invoice-lottery.png
new file mode 100644
index 0000000..331aa00
Binary files /dev/null and b/public/og/zh/invoice-lottery.png differ
diff --git a/src/data/tools.ts b/src/data/tools.ts
index 7ffa377..e489396 100644
--- a/src/data/tools.ts
+++ b/src/data/tools.ts
@@ -49,4 +49,5 @@ export const tools: Tool[] = [
{ id: 'tw-id', title: '台灣證號', titleEn: 'Taiwan ID Validator', description: '身分證與統編檢查碼驗證,附測試號碼產生。', descriptionEn: 'Validate Taiwan national IDs and business numbers, with test generators.', category: '實用工具', path: '/tools/tw-id', icon: '🪪', status: 'available', keywords: ['身分證', '統編', '統一編號', 'id', 'gui', '驗證', 'validate', '檢查碼', 'taiwan'] },
{ id: 'hash-id', title: '雜湊 / UUID', titleEn: 'Hash / UUID', description: '文字與檔案的 SHA 雜湊,加上 UUID 批次產生。', descriptionEn: 'SHA hashes for text and files, plus batch UUID generation.', category: '文字', path: '/tools/hash-id', icon: '🔏', status: 'available', keywords: ['hash', 'sha256', 'sha1', 'uuid', 'guid', '雜湊', 'checksum'] },
{ id: 'url-audit', title: '網址健檢', titleEn: 'URL Audit', description: '檢查網址的 SEO、社群分享卡與 AI 引擎設定並評分。', descriptionEn: 'Audit a URL for SEO, social share cards and AI-engine readiness, with a score.', category: '實用工具', path: '/tools/url-audit', icon: '🔎', status: 'available', keywords: ['seo', 'og', 'open graph', 'geo', '健檢', 'meta', '分享', 'social', 'audit', 'twitter card'] },
+ { id: 'invoice-lottery', title: '發票對獎', titleEn: 'Invoice Lottery', description: '財政部最新開獎號碼,輸入末 3 碼立刻對獎。', descriptionEn: 'Check Taiwan uniform-invoice numbers against the latest official draw.', category: '實用工具', path: '/tools/invoice-lottery', icon: '🧧', status: 'available', keywords: ['發票', '統一發票', '對獎', '中獎號碼', 'invoice', 'lottery', 'receipt', '雲端發票', '財政部'] },
];
diff --git a/src/lib/__tests__/invoice.test.ts b/src/lib/__tests__/invoice.test.ts
new file mode 100644
index 0000000..9743445
--- /dev/null
+++ b/src/lib/__tests__/invoice.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from 'vitest';
+import { parseInvoiceXml, checkInvoice, PRIZE_AMOUNT, type WinningNumbers } from '../invoice';
+
+// 財政部 invoice.xml 的實際格式(節錄自 https://invoice.etax.nat.gov.tw/invoice.xml)
+const REAL_XML = `
+
特獎:85941329
頭獎:07225810、20231230、83518781
]]> + +特獎:32220522
頭獎:21677046、44662410、31262513
增開六獎:985、951
]]>特獎:85941329
', ''); + const periods = parseInvoiceXml(broken); + expect(periods).toHaveLength(1); + expect(periods[0].period).toBe('115年 01~02月'); + }); + + it('垃圾輸入回空陣列', () => { + expect(parseInvoiceXml('not xml at all')).toEqual([]); + expect(parseInvoiceXml('')).toEqual([]); + }); +}); + +const W: WinningNumbers = { + period: '115年 01~02月', + link: '', + special: '87510041', + grand: '32220522', + first: ['21677046', '44662410', '31262513'], + sixth: ['985', '951'], +}; + +describe('checkInvoice(完整 8 碼)', () => { + it('特別獎:8 碼全中 1,000 萬', () => { + expect(checkInvoice('87510041', W)).toEqual({ level: 'special', amount: 10_000_000, matched: '87510041', confirmed: true, upgradable: false }); + }); + + it('特獎:8 碼全中 200 萬', () => { + expect(checkInvoice('32220522', W)).toMatchObject({ level: 'grand', amount: 2_000_000 }); + }); + + it('頭獎全中 20 萬;末 7~3 碼對應二獎到六獎', () => { + expect(checkInvoice('21677046', W)).toMatchObject({ level: 'first', amount: 200_000 }); + expect(checkInvoice('91677046', W)).toMatchObject({ level: 'second', amount: 40_000 }); + expect(checkInvoice('99677046', W)).toMatchObject({ level: 'third', amount: 10_000 }); + expect(checkInvoice('99977046', W)).toMatchObject({ level: 'fourth', amount: 4_000 }); + expect(checkInvoice('99997046', W)).toMatchObject({ level: 'fifth', amount: 1_000 }); + expect(checkInvoice('99999046', W)).toMatchObject({ level: 'sixth', amount: 200 }); + }); + + it('增開六獎:末 3 碼相同 200 元', () => { + expect(checkInvoice('12345985', W)).toMatchObject({ level: 'addSixth', amount: 200 }); + }); + + it('特別獎末碼近似但非全中不算獎(特別獎只比全 8 碼)', () => { + // 末 7 碼與特別獎相同、與頭獎無關 → 沒中 + expect(checkInvoice('97510041', W)).toBeNull(); + }); + + it('多重命中取最高獎金', () => { + // 同時是頭獎末4(五獎)與增開六獎? 構造:末3=985 且末4 對中頭獎末4 —— 取五獎 1000 + const w2: WinningNumbers = { ...W, first: ['11112985'], sixth: ['985'] }; + expect(checkInvoice('99992985', w2)).toMatchObject({ level: 'fifth', amount: 1_000 }); + }); + + it('沒中回 null', () => { + expect(checkInvoice('00000000', W)).toBeNull(); + }); +}); + +describe('checkInvoice(末 3~7 碼快速對獎)', () => { + it('末 3 碼對中頭獎末 3 → 至少六獎已確定,且可能升級', () => { + const hit = checkInvoice('046', W)!; + expect(hit).toMatchObject({ level: 'sixth', amount: 200, confirmed: true }); + expect(hit.upgradable).toBe(true); // 補完 8 碼可能是二獎以上 + }); + + it('末 3 碼對中增開六獎 → 200 元確定,不會再升級', () => { + const hit = checkInvoice('951', W)!; + expect(hit).toMatchObject({ level: 'addSixth', amount: 200, confirmed: true }); + expect(hit.upgradable).toBe(false); + }); + + it('末 3 碼只對中特別獎末 3 → 未確定(特別獎要全 8 碼),confirmed false', () => { + const hit = checkInvoice('041', W)!; + expect(hit.confirmed).toBe(false); + expect(hit.level).toBe('special'); + }); + + it('末 3 碼什麼都沒對中 → null(可以確定沒中,不用再輸入)', () => { + expect(checkInvoice('000', W)).toBeNull(); + }); + + it('末 5 碼對中頭獎末 5 → 四獎確定、可能再升級', () => { + expect(checkInvoice('77046', W)).toMatchObject({ level: 'fourth', amount: 4_000, confirmed: true, upgradable: true }); + expect(checkInvoice('62513', W)).toMatchObject({ level: 'fourth', amount: 4_000, confirmed: true }); + }); + + it('少於 3 碼或含非數字 → null(無效輸入)', () => { + expect(checkInvoice('04', W)).toBeNull(); + expect(checkInvoice('abc46', W)).toBeNull(); + expect(checkInvoice('', W)).toBeNull(); + }); +}); + +describe('PRIZE_AMOUNT', () => { + it('獎金表符合財政部公告', () => { + expect(PRIZE_AMOUNT.special).toBe(10_000_000); + expect(PRIZE_AMOUNT.grand).toBe(2_000_000); + expect(PRIZE_AMOUNT.first).toBe(200_000); + expect(PRIZE_AMOUNT.sixth).toBe(200); + expect(PRIZE_AMOUNT.addSixth).toBe(200); + }); +}); diff --git a/src/lib/invoice.ts b/src/lib/invoice.ts new file mode 100644 index 0000000..04676e6 --- /dev/null +++ b/src/lib/invoice.ts @@ -0,0 +1,115 @@ +// 統一發票對獎:財政部 invoice.xml 解析與對獎規則。純函式, +// 由 api/invoice-numbers.ts(解析)與 InvoiceLottery 島(對獎)共用。 +// 號碼一律以字串處理,前導零不能掉(例:特獎 00507588)。 + +export interface WinningNumbers { + period: string; // 期別,如「115年 03~04月」 + link: string; // 財政部該期公告頁 + special: string; // 特別獎(8 碼,全中 1,000 萬) + grand: string; // 特獎(8 碼,全中 200 萬) + first: string[]; // 頭獎(8 碼三組;末 7~3 碼對應二獎~六獎) + sixth: string[]; // 增開六獎(3 碼,可能沒開) +} + +export type PrizeLevel = 'special' | 'grand' | 'first' | 'second' | 'third' | 'fourth' | 'fifth' | 'sixth' | 'addSixth'; + +export const PRIZE_AMOUNT: Record{t.tooShort}
; + const hit = checkInvoice(digits, w); + if (!hit) { + return ( ++ {digits.length < 8 ? t.noneShort(digits.length) : t.noneFull} +
+ ); + } + const name = t.levelNames[hit.level]; + if (!hit.confirmed) { + return{t.maybe(name)}
; + } + return ( +{t.win(name, hit.amount.toLocaleString())}
+{t.winMatched(hit.matched)}
+ {hit.upgradable &&{t.upgradeHint}
} +{t.loadError}
+ +{t.loading}
; + + const w = periods[Math.min(periodIdx, periods.length - 1)]; + + return ( +{t.rules}
+{t.privacy}
+