From b8b384ed67be3f9a8f59fdd24f94687ea1437604 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 09:40:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(M01-B):=20=E4=B8=A5=E6=A0=BC=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E6=97=A7=E5=BA=93=E8=AE=A2=E5=8D=95=E9=87=91=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/db/legacy-money.ts | 56 ++++++++++ backend/src/db/legacy-read-repository.ts | 102 +++++++++++++++++- backend/tests/legacy-money.test.mjs | 45 ++++++++ backend/tests/legacy-read-repository.test.mjs | 73 ++++++++++++- 5 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 backend/src/db/legacy-money.ts create mode 100644 backend/tests/legacy-money.test.mjs diff --git a/backend/package.json b/backend/package.json index 7420e38..fa8128c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,7 @@ "db:migrate:up": "npm run build && node dist/db/migrate-cli.js up", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-read-repository.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/db/legacy-money.ts b/backend/src/db/legacy-money.ts new file mode 100644 index 0000000..ee12f7c --- /dev/null +++ b/backend/src/db/legacy-money.ts @@ -0,0 +1,56 @@ +export const MYSQL_UNSIGNED_INT_MAX = 4_294_967_295; + +export interface LegacyMoneyOptions { + nullable?: boolean; + maxCents?: number; + field?: string; +} + +function describeField(field: string | undefined): string { + return field ? ` for ${field}` : ''; +} + +export function legacyDecimalToCents( + value: unknown, + options: LegacyMoneyOptions = {} +): number | null { + const field = describeField(options.field); + + if (value === null || value === undefined) { + if (options.nullable) { + return null; + } + throw new Error(`Legacy money value${field} cannot be null.`); + } + + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error(`Legacy money value${field} must be a decimal string or number.`); + } + + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error(`Legacy money value${field} must be finite.`); + } + + const decimal = String(value); + const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(decimal); + if (!match) { + throw new Error( + `Legacy money value${field} must be a non-negative decimal with at most two fractional digits.` + ); + } + + const wholeCents = BigInt(match[1]) * 100n; + const fraction = (match[2] ?? '').padEnd(2, '0'); + const cents = wholeCents + BigInt(fraction || '0'); + const maxCents = options.maxCents ?? MYSQL_UNSIGNED_INT_MAX; + + if (!Number.isSafeInteger(maxCents) || maxCents < 0) { + throw new Error('Legacy money maxCents must be a non-negative safe integer.'); + } + + if (cents > BigInt(maxCents)) { + throw new Error(`Legacy money value${field} exceeds the target cents column limit.`); + } + + return Number(cents); +} diff --git a/backend/src/db/legacy-read-repository.ts b/backend/src/db/legacy-read-repository.ts index f8fd104..29b8368 100644 --- a/backend/src/db/legacy-read-repository.ts +++ b/backend/src/db/legacy-read-repository.ts @@ -1,4 +1,5 @@ import type { MySqlPool } from './mysql.js'; +import { legacyDecimalToCents } from './legacy-money.js'; type LegacyEntity = 'stores' | 'rooms' | 'orders' | 'devices'; @@ -12,6 +13,11 @@ export interface LegacyTableMapping { codeColumn?: string; startColumn?: string; endColumn?: string; + totalAmountColumn?: string; + paidAmountColumn?: string; + renewalAmountColumn?: string; + groupAmountColumn?: string; + refundAmountColumn?: string; } export interface LegacyReadOptions { @@ -29,6 +35,11 @@ export interface LegacyRecord { status: string | null; startAt: Date | string | null; endAt: Date | string | null; + totalAmountCents: number | null; + paidAmountCents: number | null; + renewalAmountCents: number | null; + groupAmountCents: number | null; + refundAmountCents: number | null; } export const defaultLegacyMappings: Record = { @@ -56,7 +67,12 @@ export const defaultLegacyMappings: Record = { codeColumn: 'order_no', statusColumn: 'status', startColumn: 'start_time', - endColumn: 'end_time' + endColumn: 'end_time', + totalAmountColumn: 'price', + paidAmountColumn: 'pay_price', + renewalAmountColumn: 'renew_price', + groupAmountColumn: 'group_pay_price', + refundAmountColumn: 'refund_price' }, devices: { table: 'member_device_info', @@ -89,6 +105,81 @@ function normalizeLimit(limit = 100): number { return limit; } +interface LegacyQueryRow extends Omit< + LegacyRecord, + | 'totalAmountCents' + | 'paidAmountCents' + | 'renewalAmountCents' + | 'groupAmountCents' + | 'refundAmountCents' +> { + totalAmountDecimal: unknown; + paidAmountDecimal: unknown; + renewalAmountDecimal: unknown; + groupAmountDecimal: unknown; + refundAmountDecimal: unknown; +} + +function normalizeMoney( + value: unknown, + mapping: LegacyTableMapping, + column: string | undefined, + nullable: boolean +): number | null { + if (!column) { + return null; + } + return legacyDecimalToCents(value, { + field: `${mapping.table}.${column}`, + nullable + }); +} + +function normalizeRecord(row: LegacyQueryRow, mapping: LegacyTableMapping): LegacyRecord { + const { + totalAmountDecimal, + paidAmountDecimal, + renewalAmountDecimal, + groupAmountDecimal, + refundAmountDecimal, + ...record + } = row; + + return { + ...record, + totalAmountCents: normalizeMoney( + totalAmountDecimal, + mapping, + mapping.totalAmountColumn, + false + ), + paidAmountCents: normalizeMoney( + paidAmountDecimal, + mapping, + mapping.paidAmountColumn, + true + ), + renewalAmountCents: normalizeMoney( + renewalAmountDecimal, + mapping, + mapping.renewalAmountColumn, + true + ), + groupAmountCents: normalizeMoney( + groupAmountDecimal, + mapping, + mapping.groupAmountColumn, + true + ), + refundAmountCents: normalizeMoney( + refundAmountDecimal, + mapping, + mapping.refundAmountColumn, + true + ) + }; +} + export class LegacyReadRepository { constructor( private readonly pool: Pick, @@ -132,7 +223,12 @@ export class LegacyReadRepository { `${selectedColumn(mapping.codeColumn, 'code')},`, `${selectedColumn(mapping.statusColumn, 'status')},`, `${selectedColumn(mapping.startColumn, 'startAt')},`, - selectedColumn(mapping.endColumn, 'endAt'), + `${selectedColumn(mapping.endColumn, 'endAt')},`, + `${selectedColumn(mapping.totalAmountColumn, 'totalAmountDecimal')},`, + `${selectedColumn(mapping.paidAmountColumn, 'paidAmountDecimal')},`, + `${selectedColumn(mapping.renewalAmountColumn, 'renewalAmountDecimal')},`, + `${selectedColumn(mapping.groupAmountColumn, 'groupAmountDecimal')},`, + selectedColumn(mapping.refundAmountColumn, 'refundAmountDecimal'), `FROM ${quoteIdentifier(mapping.table)}`, `WHERE ${clauses.join(' AND ')}`, `ORDER BY ${quoteIdentifier(mapping.idColumn)} ASC`, @@ -140,6 +236,6 @@ export class LegacyReadRepository { ].join(' '); const [rows] = await this.pool.query(sql, parameters); - return rows as LegacyRecord[]; + return (rows as LegacyQueryRow[]).map((row) => normalizeRecord(row, mapping)); } } diff --git a/backend/tests/legacy-money.test.mjs b/backend/tests/legacy-money.test.mjs new file mode 100644 index 0000000..18b6864 --- /dev/null +++ b/backend/tests/legacy-money.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { + legacyDecimalToCents, + MYSQL_UNSIGNED_INT_MAX +} from '../dist/db/legacy-money.js'; + +assert.equal(legacyDecimalToCents('0'), 0); +assert.equal(legacyDecimalToCents('0.01'), 1); +assert.equal(legacyDecimalToCents('12.3'), 1230); +assert.equal(legacyDecimalToCents('12.34'), 1234); +assert.equal(legacyDecimalToCents(12.34), 1234); +assert.equal(legacyDecimalToCents('42949672.95'), MYSQL_UNSIGNED_INT_MAX); +assert.equal(legacyDecimalToCents(null, { nullable: true }), null); + +for (const invalidValue of [ + '', + ' 1.00', + '1.00 ', + '-0.01', + '+1.00', + '1.001', + '1e2', + 'NaN', + Number.NaN, + Number.POSITIVE_INFINITY, + {}, + null +]) { + assert.throws( + () => legacyDecimalToCents(invalidValue), + /Legacy money/ + ); +} + +assert.throws( + () => legacyDecimalToCents('42949672.96', { field: 'member_order_info.price' }), + /member_order_info\.price.*exceeds/ +); + +assert.throws( + () => legacyDecimalToCents('1.00', { maxCents: Number.MAX_SAFE_INTEGER + 1 }), + /maxCents/ +); + +console.log('PASS: legacy DECIMAL money conversion is exact and bounded.'); diff --git a/backend/tests/legacy-read-repository.test.mjs b/backend/tests/legacy-read-repository.test.mjs index 69c034d..8fad333 100644 --- a/backend/tests/legacy-read-repository.test.mjs +++ b/backend/tests/legacy-read-repository.test.mjs @@ -5,6 +5,7 @@ const calls = []; const fakePool = { async query(sql, parameters) { calls.push({ sql, parameters }); + const isOrderQuery = /FROM `member_order_info`/.test(sql); return [[{ legacyId: '7', tenantId: '2', @@ -13,7 +14,12 @@ const fakePool = { code: 'A01', status: 'OPEN', startAt: null, - endAt: null + endAt: null, + totalAmountDecimal: isOrderQuery ? '25.80' : null, + paidAmountDecimal: isOrderQuery ? '20.00' : null, + renewalAmountDecimal: isOrderQuery ? '5.8' : null, + groupAmountDecimal: isOrderQuery ? '0.00' : null, + refundAmountDecimal: null }], []]; } }; @@ -22,6 +28,11 @@ const repository = new LegacyReadRepository(fakePool); const rooms = await repository.listRooms({ tenantId: 2, parentId: 3, limit: 25 }); assert.equal(rooms.length, 1); +assert.equal(rooms[0].totalAmountCents, null); +assert.equal(rooms[0].paidAmountCents, null); +assert.equal(rooms[0].renewalAmountCents, null); +assert.equal(rooms[0].groupAmountCents, null); +assert.equal(rooms[0].refundAmountCents, null); assert.equal(calls.length, 1); assert.match(calls[0].sql, /FROM `member_room_info`/); assert.match(calls[0].sql, /`tenant_id` = \?/); @@ -30,6 +41,16 @@ assert.match(calls[0].sql, /ORDER BY `id` ASC LIMIT \?/); assert.deepEqual(calls[0].parameters, [2, 3, 25]); assert.doesNotMatch(calls[0].sql, /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i); +const orders = await repository.listOrders({ tenantId: 2, parentId: 3, limit: 10 }); +assert.equal(orders[0].totalAmountCents, 2580); +assert.equal(orders[0].paidAmountCents, 2000); +assert.equal(orders[0].renewalAmountCents, 580); +assert.equal(orders[0].groupAmountCents, 0); +assert.equal(orders[0].refundAmountCents, null); +assert.match(calls[1].sql, /`price` AS `totalAmountDecimal`/); +assert.match(calls[1].sql, /`pay_price` AS `paidAmountDecimal`/); +assert.match(calls[1].sql, /`refund_price` AS `refundAmountDecimal`/); + await assert.rejects( () => repository.listStores({ tenantId: 2, limit: 501 }), /between 1 and 500/ @@ -63,4 +84,54 @@ await assert.rejects( /Unsafe legacy SQL identifier/ ); +const invalidMoneyRepository = new LegacyReadRepository({ + async query() { + return [[{ + legacyId: '8', + tenantId: '2', + parentId: '3', + name: null, + code: 'ORDER-8', + status: 'PAID', + startAt: null, + endAt: null, + totalAmountDecimal: '1.001', + paidAmountDecimal: '1.00', + renewalAmountDecimal: '0.00', + groupAmountDecimal: '0.00', + refundAmountDecimal: '0.00' + }], []]; + } +}); + +await assert.rejects( + () => invalidMoneyRepository.listOrders({ tenantId: 2 }), + /member_order_info\.price/ +); + +const missingRequiredMoneyRepository = new LegacyReadRepository({ + async query() { + return [[{ + legacyId: '9', + tenantId: '2', + parentId: '3', + name: null, + code: 'ORDER-9', + status: 'PAID', + startAt: null, + endAt: null, + totalAmountDecimal: null, + paidAmountDecimal: null, + renewalAmountDecimal: null, + groupAmountDecimal: null, + refundAmountDecimal: null + }], []]; + } +}); + +await assert.rejects( + () => missingRequiredMoneyRepository.listOrders({ tenantId: 2 }), + /member_order_info\.price.*cannot be null/ +); + console.log('PASS: legacy read-only repository contracts are present.');