积分与余额账务系统硬切重构

This commit is contained in:
kate522 committed 2026-08-24 15:59:11 +08:00
1 parent ca12226b39
commit 5cae5b5c93
159 files changed
+7275 -4718

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
<?php
use AKSRT\Ledger\AssetAmount;
use AKSRT\Ledger\AssetType;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class AssetAmountTest extends TestCase {
#[DataProvider('validBalanceProvider')]
public function test_balance_parses_exact_decimal_strings(string $input, int $minor, string $decimal): void {
$amount = AssetAmount::balance($input);
self::assertSame(AssetType::BALANCE, $amount->asset());
self::assertSame($minor, $amount->minor());
self::assertSame($decimal, $amount->decimal());
}
public static function validBalanceProvider(): array {
return [
'zero' => ['0', 0, '0.00'],
'one decimal' => ['12.3', 1230, '12.30'],
'two decimals' => ['12.34', 1234, '12.34'],
'integer' => ['999', 99900, '999.00'],
];
}
#[DataProvider('invalidBalanceProvider')]
public function test_balance_rejects_non_canonical_input(string $input): void {
$this->expectException(InvalidArgumentException::class);
AssetAmount::balance($input);
}
public static function invalidBalanceProvider(): array {
return [
'negative' => ['-1.00'],
'float precision' => ['1.001'],
'scientific notation' => ['1e2'],
'leading zero' => ['01.00'],
'leading decimal' => ['.50'],
'trailing decimal' => ['1.'],
'plus sign' => ['+1.00'],
'comma' => ['1,00'],
'empty' => [''],
];
}
public function test_balance_rejects_integer_overflow(): void {
$this->expectException(InvalidArgumentException::class);
AssetAmount::balance((string) PHP_INT_MAX . '.99');
}
public function test_points_are_integer_minor_units(): void {
self::assertSame(37, AssetAmount::points(37)->minor());
self::assertSame('37', AssetAmount::points(37)->decimal());
}
public function test_negative_points_are_rejected(): void {
$this->expectException(InvalidArgumentException::class);
AssetAmount::points(-1);
}
public function test_balance_minor_helpers_do_not_use_floats(): void {
self::assertSame(1234, AssetAmount::balance_minor('12.34'));
self::assertSame('12.34', AssetAmount::decimal_from_minor(1234));
}
public function test_percentage_rounds_half_up_in_minor_units(): void {
self::assertSame(266, AssetAmount::percentage(333, 80));
self::assertSame(1, AssetAmount::percentage(1, 50));
self::assertSame(0, AssetAmount::percentage(1, 49));
}
public function test_percentage_rejects_out_of_range_values(): void {
$this->expectException(InvalidArgumentException::class);
AssetAmount::percentage(100, 101);
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
use AKSRT\Ledger\AssetType;
use AKSRT\Ledger\LedgerCommand;
use PHPUnit\Framework\TestCase;
final class LedgerCommandTest extends TestCase {
public function test_transfer_splits_principal_fee_and_net_receipt(): void {
$command = LedgerCommand::transfer(
'client-transfer:1:550e8400-e29b-41d4-a716-446655440000',
'transfer',
10,
20,
AssetType::BALANCE,
1000,
125
);
self::assertSame([-875, -125, 875], array_column($command->entries, 'delta_minor'));
self::assertSame(['transfer_out', 'fee', 'transfer_in'], array_column($command->entries, 'entry_type'));
self::assertSame(1000, $command->metadata['gross_minor']);
self::assertSame(125, $command->metadata['fee_minor']);
self::assertSame(875, $command->metadata['net_minor']);
self::assertSame(-125, array_sum(array_column($command->entries, 'delta_minor')));
}
public function test_transfer_rejects_net_below_one_minor_unit(): void {
$this->expectException(InvalidArgumentException::class);
LedgerCommand::transfer('transfer:key', 'transfer', 1, 2, AssetType::POINTS, 10, 10);
}
public function test_transfer_rejects_negative_fee_before_arithmetic(): void {
$this->expectException(InvalidArgumentException::class);
LedgerCommand::transfer('transfer:key', 'transfer', 1, 2, AssetType::POINTS, PHP_INT_MAX, -1);
}
public function test_validation_returns_400_for_bad_operation_key(): void {
$command = new LedgerCommand('bad key', 'credit', [[
'user_id' => 1,
'asset' => 'points',
'delta_minor' => 1,
'entry_type' => 'credit',
]]);
$error = $command->validate();
self::assertInstanceOf(WP_Error::class, $error);
self::assertSame('aksrt_ledger_operation_key', $error->get_error_code());
self::assertSame(400, $error->get_error_data()['status']);
}
public function test_payload_hash_is_stable_for_metadata_key_order(): void {
$entries = [['user_id' => 1, 'asset' => 'points', 'delta_minor' => 2, 'entry_type' => 'credit']];
$first = new LedgerCommand('stable:key', 'credit', $entries, '', '', 0, ['b' => 2, 'a' => 1]);
$second = new LedgerCommand('stable:key', 'credit', $entries, '', '', 0, ['a' => 1, 'b' => 2]);
self::assertSame($first->payload_hash(), $second->payload_hash());
}
public function test_payload_hash_changes_with_business_payload(): void {
$first = new LedgerCommand('stable:key', 'credit', [[
'user_id' => 1,
'asset' => 'points',
'delta_minor' => 2,
'entry_type' => 'credit',
]]);
$second = new LedgerCommand('stable:key', 'credit', [[
'user_id' => 1,
'asset' => 'points',
'delta_minor' => 3,
'entry_type' => 'credit',
]]);
self::assertNotSame($first->payload_hash(), $second->payload_hash());
}
public function test_validation_rejects_empty_entries_and_invalid_asset(): void {
$empty = new LedgerCommand('empty:key', 'credit', []);
self::assertSame('aksrt_ledger_entries', $empty->validate()->get_error_code());
$invalid = new LedgerCommand('asset:key', 'credit', [[
'user_id' => 1,
'asset' => 'cash',
'delta_minor' => 1,
'entry_type' => 'credit',
]]);
self::assertSame('aksrt_ledger_asset', $invalid->validate()->get_error_code());
}
public function test_validation_requires_integer_non_zero_entry_values(): void {
foreach (['1', 0, PHP_INT_MIN] as $delta) {
$command = new LedgerCommand('entry:key', 'credit', [[
'user_id' => 1,
'asset' => 'points',
'delta_minor' => $delta,
'entry_type' => 'credit',
]]);
self::assertSame('aksrt_ledger_entry', $command->validate()->get_error_code());
}
}
public function test_validation_rejects_negative_actor_and_invalid_entry_type(): void {
$actor = new LedgerCommand('actor:key', 'credit', [[
'user_id' => 1,
'asset' => 'points',
'delta_minor' => 1,
'entry_type' => 'credit',
]], '', '', -1);
self::assertSame('aksrt_ledger_actor', $actor->validate()->get_error_code());
$type = new LedgerCommand('type:key', 'credit', [[
'user_id' => 1,
'asset' => 'points',
'delta_minor' => 1,
'entry_type' => 'Bad Type',
]]);
self::assertSame('aksrt_ledger_entry', $type->validate()->get_error_code());
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
use AKSRT\Ledger\AssetType;
use AKSRT\Ledger\LedgerResult;
use PHPUnit\Framework\TestCase;
final class LedgerResultTest extends TestCase {
public function test_balance_reads_integer_minor_units(): void {
$result = new LedgerResult(17, true, [], [
4 => ['points' => 25, 'balance' => 1234],
]);
self::assertSame(25, $result->balance(4, AssetType::POINTS));
self::assertSame(1234, $result->balance(4, AssetType::BALANCE));
self::assertSame(0, $result->balance(99, AssetType::BALANCE));
self::assertSame(17, $result->to_array()['transaction_id']);
self::assertTrue($result->to_array()['replayed']);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
use AKSRT\Ledger\OperationKey;
use PHPUnit\Framework\TestCase;
final class OperationKeyTest extends TestCase {
public function test_request_key_requires_uuid_header(): void {
$error = OperationKey::from_request(new WP_REST_Request(), 'transfer', 9);
self::assertInstanceOf(WP_Error::class, $error);
self::assertSame('aksrt_idempotency_key', $error->get_error_code());
self::assertSame(400, $error->get_error_data()['status']);
}
public function test_request_key_is_scoped_to_actor_and_operation(): void {
$request = new WP_REST_Request(['Idempotency-Key' => '550E8400-E29B-41D4-A716-446655440000']);
self::assertSame(
'client-transfer:9:550e8400-e29b-41d4-a716-446655440000',
OperationKey::from_request($request, 'transfer', 9)
);
}
public function test_deterministic_key_is_stable_and_length_bounded(): void {
$key = OperationKey::deterministic('Revenue Settle', [42, str_repeat('A', 300)]);
self::assertSame($key, OperationKey::deterministic('Revenue Settle', [42, str_repeat('A', 300)]));
self::assertLessThanOrEqual(191, strlen($key));
self::assertMatchesRegularExpression('/^[a-z0-9:._-]+$/', $key);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
define('ABSPATH', __DIR__ . '/');
if (!function_exists('__')) {
function __(string $text, string $domain = ''): string {
return $text;
}
}
if (!function_exists('sanitize_key')) {
function sanitize_key(string $key): string {
return strtolower((string) preg_replace('/[^a-z0-9_\-]/', '', $key));
}
}
if (!function_exists('wp_json_encode')) {
function wp_json_encode($value, int $flags = 0) {
return json_encode($value, $flags);
}
}
if (!function_exists('is_wp_error')) {
function is_wp_error($value): bool {
return $value instanceof WP_Error;
}
}
if (!function_exists('wp_is_uuid')) {
function wp_is_uuid($uuid, $version = null): bool {
$pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-([0-9a-f])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i';
if (!is_string($uuid) || !preg_match($pattern, $uuid, $matches)) {
return false;
}
return $version === null || strtolower($matches[1]) === strtolower((string) $version);
}
}
if (!class_exists('WP_Error')) {
class WP_Error {
public function __construct(private string $code, private string $message = '', private $data = null) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data() { return $this->data; }
}
}
if (!class_exists('WP_REST_Request')) {
class WP_REST_Request {
/** @param array<string,string> $headers */
public function __construct(private array $headers = []) {}
public function get_header(string $name): string {
foreach ($this->headers as $key => $value) {
if (strcasecmp($key, $name) === 0) {
return $value;
}
}
return '';
}
}
}
require_once dirname(__DIR__, 2) . '/src/Ledger/AssetType.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerCommand.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerResult.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/OperationKey.php';
@@ -0,0 +1,170 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..', '..')
function sourceFiles(dir) {
const out = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'build' || entry.name === 'vendor' || entry.name === 'node_modules') continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) out.push(...sourceFiles(full))
else if (/\.(php|js|vue|cjs)$/.test(entry.name)) out.push(full)
}
return out
}
test('old asset implementations and compatibility aliases are absent', () => {
assert.equal(fs.existsSync(path.join(root, 'src', 'Credits', 'Credits.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src', 'Credits', 'Balance.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src', 'Payment', 'Rebate.php')), false)
assert.equal(fs.existsSync(path.join(root, 'src', 'Admin', 'Native', 'PayRebate.php')), false)
assert.equal(fs.existsSync(path.join(root, 'assets', 'admin', 'src', 'pages', 'payment', 'PaymentRebate.vue')), false)
assert.equal(fs.existsSync(path.join(root, 'assets', 'admin', 'src', 'pages', 'payment', 'components', 'RebateDetailDrawer.vue')), false)
const files = [
...sourceFiles(path.join(root, 'src')),
...sourceFiles(path.join(root, 'inc')),
...sourceFiles(path.join(root, 'assets', 'js')),
...sourceFiles(path.join(root, 'templates')),
]
const forbidden = [
/aksrt_credits(?:\b|_)/,
/['"]aksrt_balance['"]/,
/\bcredit_log\b/,
/\bbalance_log\b/,
/aksrt_credits_(?:added|deducted)/,
/aksrt_balance_(?:added|deducted)/,
/credits_(?:added|deducted)/,
/points_pass_exchange_/,
/legacy_task_points/,
/transfer_refund|recovery_failed/,
/\bPayRebate\b/,
/\bPaymentRebate\b/,
/\bRebateDetailDrawer\b/,
/\brebate_log\b/,
/\/payment\/rebate(?:\b|\/)/,
/\bpay_rebate\b/,
/aksrtpay_rebate_page/,
]
const violations = []
for (const file of files) {
const text = fs.readFileSync(file, 'utf8')
for (const pattern of forbidden) {
if (pattern.test(text)) violations.push(`${path.relative(root, file)}: ${pattern}`)
}
}
assert.deepEqual(violations, [])
})
test('asset tables are InnoDB and operation keys are unique', () => {
const schema = fs.readFileSync(path.join(root, 'src', 'Core', 'DBMigratorPart2.php'), 'utf8')
for (const table of ['asset_account', 'asset_transaction', 'asset_entry']) {
assert.match(schema, new RegExp(`CREATE TABLE \\{\\$prefix\\}aksrt_${table} \\([\\s\\S]*?\\) ENGINE=InnoDB`))
}
assert.match(schema, /UNIQUE KEY uniq_operation_key \(operation_key\)/)
assert.match(schema, /UNIQUE KEY uniq_transaction_line \(transaction_id, line_no\)/)
})
test('user asset submissions send idempotency keys', () => {
const request = fs.readFileSync(path.join(root, 'assets', 'js', 'core', 'request.js'), 'utf8')
assert.match(request, /createIdempotencyKey/)
for (const file of ['credits.js', 'reward.js', 'vip-purchase-coordinator.js', 'cms-download.js']) {
const source = fs.readFileSync(path.join(root, 'assets', 'js', file), 'utf8')
assert.match(source, /Idempotency-Key/, `${file} must send Idempotency-Key`)
}
})
test('core asset paths never use floating-point money helpers', () => {
const files = [
'src/Ledger/AssetAmount.php',
'src/Ledger/LedgerCommand.php',
'src/Ledger/LedgerService.php',
'src/Credits/BalanceService.php',
'src/Credits/BalanceServiceCharge.php',
'src/Credits/BalanceServiceRest.php',
'src/Payment/BalanceGateway.php',
'src/Payment/ChargeCard.php',
'src/Payment/CommissionLedger.php',
'src/Payment/Revenue.php',
'src/Payment/Withdrawal.php',
'src/Admin/UserController.php',
'src/Content/PayRest.php',
'src/Content/VipServicePurchase.php',
]
const forbidden = [/\(float\)/, /floatval\s*\(/, /round\s*\(/, /number_format\s*\(/]
const violations = []
for (const relative of files) {
const text = fs.readFileSync(path.join(root, relative), 'utf8')
for (const pattern of forbidden) {
if (pattern.test(text)) violations.push(`${relative}: ${pattern}`)
}
}
assert.deepEqual(violations, [])
})
test('content preview is read-only while purchase requires an operation key', () => {
const source = fs.readFileSync(path.join(root, 'src', 'Content', 'PayRest.php'), 'utf8')
const previewStart = source.indexOf('public function rest_preview')
const purchaseStart = source.indexOf('public function rest_purchase')
const purchaseEnd = source.indexOf('public function rest_order_status', purchaseStart)
assert.ok(previewStart >= 0 && purchaseStart > previewStart && purchaseEnd > purchaseStart)
assert.doesNotMatch(source.slice(previewStart, purchaseStart), /OperationKey::from_request/)
assert.match(source.slice(purchaseStart, purchaseEnd), /OperationKey::from_request\(\$request, 'content-purchase'/)
})
test('native financial actions and REST adjustments carry UUID operation keys', () => {
const income = fs.readFileSync(path.join(root, 'src', 'Admin', 'Native', 'PayIncome.php'), 'utf8')
const withdrawals = fs.readFileSync(path.join(root, 'src', 'Admin', 'Native', 'PayWithdrawals.php'), 'utf8')
const points = fs.readFileSync(path.join(root, 'src', 'User', 'CreditsAdmin.php'), 'utf8')
const balance = fs.readFileSync(path.join(root, 'src', 'User', 'BalanceAdmin.php'), 'utf8')
const restUsers = fs.readFileSync(path.join(root, 'src', 'Admin', 'UserController.php'), 'utf8')
for (const source of [income, withdrawals, points, balance]) {
assert.match(source, /wp_generate_uuid4\(\)/)
assert.match(source, /wp_is_uuid\(/)
}
assert.match(points, /PHP_INT_MAX/)
assert.match(points, /preg_match\('\/\^-\?/)
assert.match(points, /strcmp\(\$unsigned_delta, \$max_integer\)/)
assert.match(points, /current_user_can\('edit_user', \$user_id\)/)
assert.match(balance, /current_user_can\('edit_user', \$user_id\)/)
assert.match(restUsers, /OperationKey::from_request\(\$request, 'admin-adjustment'/)
assert.match(restUsers, /current_user_can\('edit_user', \$user_id\)/)
})
test('charge cards have no legacy input or metadata aliases', () => {
const source = fs.readFileSync(path.join(root, 'src', 'Payment', 'ChargeCard.php'), 'utf8')
assert.doesNotMatch(source, /\$params\[['"]code['"]\]/)
assert.doesNotMatch(source, /\$data\[['"]code['"]\]/)
assert.doesNotMatch(source, /\$raw\[['"]vip_days['"]\]/)
assert.doesNotMatch(source, /\$raw\[['"]credits['"]\]/)
assert.match(source, /\$params\['password'\]/)
assert.match(source, /\$raw\['days'\]/)
assert.match(source, /\$raw\['points'\]/)
})
test('VIP card redemption is one locked transaction with bounded retry and replay checks', () => {
const source = fs.readFileSync(path.join(root, 'src', 'Payment', 'ChargeCard.php'), 'utf8')
const start = source.indexOf('private function redeem_vip_card')
const end = source.indexOf('private function vip_card_replay_result', start)
const method = source.slice(start, end)
assert.ok(start >= 0 && end > start)
assert.match(method, /for \(\$attempt = 1; \$attempt <= 3; \$attempt\+\+\)/)
assert.match(method, /START TRANSACTION/)
assert.match(method, /FOR UPDATE/)
assert.match(method, /request_key = %s FOR UPDATE/)
assert.match(method, /COMMIT/)
assert.match(method, /ROLLBACK/)
assert.match(method, /\[1205, 1213\]/)
assert.match(method, /vip_card_replay_result/)
})
test('admin ledger keeps cash minor values as decimal strings', () => {
const controller = fs.readFileSync(path.join(root, 'src', 'Admin', 'UserController.php'), 'utf8')
const page = fs.readFileSync(path.join(root, 'assets', 'admin', 'src', 'pages', 'CmsCredits.vue'), 'utf8')
assert.match(controller, /asset_minor_for_response/)
assert.match(controller, /decimal_from_minor/)
assert.doesNotMatch(page, /\/\s*100(?:\D|$)/)
})