diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 000000000..a4affc8ed
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,22 @@
+parameters:
+ level: 3
+ paths:
+ - app/
+ - config/
+ - plugin/
+ excludePaths:
+ - */runtime/*
+ - */vendor/*
+ - */tests/*
+ scanFiles:
+ - vendor/topthink/framework/src/helper.php
+ - vendor/topthink/think-orm/src/helper.php
+ - vendor/topthink/think-validate/src/helper.php
+ - plugin/think-library/src/common.php
+ - plugin/think-plugs-system/src/common.php
+ - plugin/think-plugs-worker/src/common.php
+ - plugin/think-plugs-wemall/src/common.php
+ - phpstan/stubs/legacy-plugin-aliases.php
+ - phpstan/stubs/composer-plugin-runtime.php
+ bootstrapFiles:
+ - tests/bootstrap.php
diff --git a/phpstan/stubs/composer-plugin-runtime.php b/phpstan/stubs/composer-plugin-runtime.php
new file mode 100644
index 000000000..6fff6358e
--- /dev/null
+++ b/phpstan/stubs/composer-plugin-runtime.php
@@ -0,0 +1,52 @@
+
+ */
+ public static function getSubscribedEvents(): array;
+}
diff --git a/phpstan/stubs/legacy-plugin-aliases.php b/phpstan/stubs/legacy-plugin-aliases.php
new file mode 100644
index 000000000..268086278
--- /dev/null
+++ b/phpstan/stubs/legacy-plugin-aliases.php
@@ -0,0 +1,59 @@
+
+
+
+
+ plugin/think-library/tests/CodeTest.php
+ plugin/think-library/tests/JwtTest.php
+ plugin/think-library/tests/CommonFunctionsTest.php
+ plugin/think-library/tests/ArchitectureBoundaryTest.php
+ plugin/think-library/tests/ComposerInstallBoundaryTest.php
+ plugin/think-library/tests/RouteTemplateBoundaryTest.php
+ plugin/think-library/tests/MultAccessDispatchTest.php
+ plugin/think-library/tests/ComposerDependencyBoundaryTest.php
+ plugin/think-library/tests/MigrationOwnershipTest.php
+ plugin/think-library/tests/FormBuilderTest.php
+ plugin/think-library/tests/PageBuilderTest.php
+ plugin/think-library/tests/RequestTokenServiceTest.php
+ plugin/think-plugs-install/tests/InstallCommandTest.php
+ plugin/think-plugs-system/tests/helper/PublishTest.php
+ plugin/think-plugs-system/tests/helper/IndexNameServiceTest.php
+ plugin/think-plugs-system/tests/helper/PluginMenuServiceTest.php
+ plugin/think-plugs-system/tests/RbacAccessTest.php
+ plugin/think-plugs-system/tests/ApiSystemControllerTest.php
+ plugin/think-plugs-system/tests/ApiQueueControllerTest.php
+ plugin/think-plugs-system/tests/AuthControllerTest.php
+ plugin/think-plugs-system/tests/BaseControllerTest.php
+ plugin/think-plugs-system/tests/ConfigPageRenderTest.php
+ plugin/think-plugs-system/tests/IndexControllerTest.php
+ plugin/think-plugs-system/tests/LoginControllerTest.php
+ plugin/think-plugs-system/tests/MenuControllerTest.php
+ plugin/think-plugs-system/tests/OplogControllerTest.php
+ plugin/think-plugs-system/tests/PlugsControllerTest.php
+ plugin/think-plugs-system/tests/QueueControllerTest.php
+ plugin/think-plugs-system/tests/UploadControllerTest.php
+ plugin/think-plugs-system/tests/UserControllerTest.php
+ plugin/think-plugs-system/tests/FileControllerTest.php
+ plugin/think-plugs-system/tests/ConsoleCssUtilityTest.php
+ plugin/think-plugs-account/tests/AccountRuntimeTest.php
+ plugin/think-plugs-account/tests/AccountIntegrationTest.php
+ plugin/think-plugs-account/tests/AccountCenterControllerTest.php
+ plugin/think-plugs-account/tests/AccountAdminListControllerTest.php
+ plugin/think-plugs-payment/tests/PaymentTest.php
+ plugin/think-plugs-payment/tests/PaymentRecordControllerTest.php
+ plugin/think-plugs-payment/tests/PaymentLedgerControllerTest.php
+ plugin/think-plugs-payment/tests/BalanceIntegrationTest.php
+ plugin/think-plugs-payment/tests/IntegralIntegrationTest.php
+ plugin/think-plugs-payment/tests/PaymentRecordIntegrationTest.php
+ plugin/think-plugs-payment/tests/PaymentLedgerIntegrationTest.php
+ plugin/think-plugs-system/tests/CommonFunctionsTest.php
+ plugin/think-plugs-worker/tests/CommonFunctionsTest.php
+ plugin/think-plugs-worker/tests/ProcessServiceTest.php
+ plugin/think-plugs-worker/tests/WorkerConfigTest.php
+ plugin/think-plugs-worker/tests/QueueServiceTest.php
+ plugin/think-plugs-wuma/tests/CodeTest.php
+ plugin/think-plugs-wemall/tests/PaymentEventIntegrationTest.php
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 000000000..025086eec
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,69 @@
+setAccessible(true);
+ $reflection->setValue([]);
+ }
+}
diff --git a/tests/path-examples.php b/tests/path-examples.php
new file mode 100644
index 000000000..2ce082820
--- /dev/null
+++ b/tests/path-examples.php
@@ -0,0 +1,140 @@
+ 'runtime-publish/demo.txt',
+ ]);
+ mkdir($root . '/plugin/publish-demo/stc/runtime', 0777, true);
+ file_put_contents($root . '/plugin/publish-demo/stc/runtime/demo.txt', "publish\n");
+ createPluginPackage(
+ $root,
+ 'system',
+ 'vendor/system-plugin',
+ 'plugin\system\Service',
+ [
+ '20241010000001_install_system20241010.php',
+ '20241010000002_install_storage20241010.php',
+ ]
+ );
+ createPluginPackage(
+ $root,
+ 'worker',
+ 'vendor/worker-plugin',
+ 'plugin\worker\Service',
+ '20241010000008_install_worker20241010.php'
+ );
+
+ $app = new App($root);
+ RuntimeService::init($app);
+ $app->config->set([
+ 'default' => 'file',
+ 'stores' => [
+ 'file' => ['type' => 'File', 'path' => $root . '/runtime/cache'],
+ ],
+ ], 'cache');
+
+ $command = new PublishCommand();
+ $command->setApp($app);
+
+ $code = $command->run(new Input([]), new Output('buffer'));
+ assertSameValue(0, $code, 'publish command should exit with code 0');
+
+ $services = require $root . '/vendor/services.php';
+ $versions = require $root . '/vendor/versions.php';
+ $manifest = json_decode((string)file_get_contents($root . '/database/migrations/.published.json'), true, 512, JSON_THROW_ON_ERROR);
+ $resourceManifest = json_decode((string)file_get_contents($root . '/vendor/.published.json'), true, 512, JSON_THROW_ON_ERROR);
+
+ foreach (['plugin\demo\Service', 'plugin\system\Service', 'plugin\worker\Service'] as $service) {
+ assertTrue(in_array($service, $services, true), "missing published service {$service}");
+ }
+
+ assertSameValue('System', $versions['vendor/system-plugin']['name'] ?? null, 'system plugin name should be published');
+ assertSameValue('plugin', $versions['vendor/system-plugin']['type'] ?? null, 'system plugin type should be published');
+ assertTrue(!isset($versions['vendor/storage-plugin']), 'storage plugin should no longer be published separately');
+
+ foreach ([
+ '20241010000001_install_system20241010.php',
+ '20241010000002_install_storage20241010.php',
+ '20241010000008_install_worker20241010.php',
+ ] as $migration) {
+ assertTrue(is_file($root . '/database/migrations/' . $migration), "missing migration {$migration}");
+ }
+
+ assertTrue(
+ is_file($root . '/database/migrations/20241011000001_install_wechat20241011.php'),
+ 'unmanaged unique migration should be preserved'
+ );
+
+ assertSameValue(
+ 'plugin/system/stc/database/20241010000001_install_system20241010.php',
+ $manifest['20241010000001_install_system20241010.php']['source'] ?? null,
+ 'system migration manifest should point to plugin source'
+ );
+ assertTrue(is_file($root . '/runtime-publish/demo.txt'), 'missing published runtime resource');
+ assertSameValue('vendor/publish-demo', $resourceManifest['runtime-publish/demo.txt']['package'] ?? null, 'runtime resource manifest package mismatch');
+
+ removeTree($root . '/plugin/publish-demo');
+
+ $command = new PublishCommand();
+ $command->setApp($app);
+ $code = $command->run(new Input([]), new Output('buffer'));
+ assertSameValue(0, $code, 'second publish command should exit with code 0');
+
+ $resourceManifest = json_decode((string)file_get_contents($root . '/vendor/.published.json'), true, 512, JSON_THROW_ON_ERROR);
+ assertTrue(!is_file($root . '/runtime-publish/demo.txt'), 'stale published runtime resource should be removed');
+ assertTrue(!isset($resourceManifest['runtime-publish/demo.txt']), 'stale runtime resource manifest should be removed');
+ } finally {
+ removeTree($root);
+ }
+}
+
+function runThinkListSmoke(string $projectRoot): void
+{
+ $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($projectRoot . '/think') . ' list';
+ exec($command . ' 2>&1', $output, $status);
+
+ assertSameValue(0, $status, "think list failed:\n" . implode("\n", $output));
+}
+
+function runInstallSmoke(string $projectRoot): void
+{
+ $root = sys_get_temp_dir() . '/thinkadmin-install-' . bin2hex(random_bytes(6));
+
+ try {
+ mkdir($root, 0777, true);
+ copyTree($projectRoot . '/app', $root . '/app');
+ copyTree($projectRoot . '/config', $root . '/config');
+ copyTree($projectRoot . '/plugin', $root . '/plugin');
+ copyTree($projectRoot . '/vendor', $root . '/vendor');
+ copyFile($projectRoot . '/think', $root . '/think');
+ copyFile($projectRoot . '/composer.json', $root . '/composer.json');
+
+ foreach (['database', 'public', 'runtime'] as $path) {
+ mkdir($root . '/' . $path, 0777, true);
+ }
+
+ $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/think') . ' xadmin:publish --migrate';
+ exec($command . ' 2>&1', $output, $status);
+
+ assertSameValue(0, $status, "install smoke failed:\n" . implode("\n", $output));
+
+ foreach ([
+ 'database/migrations/20241010000001_install_system20241010.php',
+ 'database/migrations/20241010000008_install_worker20241010.php',
+ 'public/static/system.js',
+ 'config/database.php',
+ ] as $path) {
+ assertTrue(is_file($root . '/' . $path), "missing installed artifact {$path}");
+ }
+
+ $db = new PDO('sqlite:' . $root . '/database/sqlite.db');
+ foreach ([
+ 'system_auth',
+ 'system_auth_node',
+ 'system_menu',
+ 'system_user',
+ 'system_data',
+ 'system_base',
+ 'system_oplog',
+ 'system_file',
+ 'system_queue',
+ ] as $table) {
+ $count = $db->query("select count(*) from sqlite_master where type='table' and name='{$table}'")->fetchColumn();
+ assertTrue(!empty($count), "missing installed table {$table}");
+ }
+
+ $dataRows = intval($db->query('select count(*) from system_data')->fetchColumn());
+ assertTrue($dataRows >= 4, 'system_data seed rows should be initialized');
+ $db = null;
+ } finally {
+ removeTree($root);
+ }
+}
+
+function assertHelperOwner(string $name, string $expectedFile): void
+{
+ assertTrue(function_exists($name), "{$name} should be defined");
+
+ $reflection = new ReflectionFunction($name);
+
+ assertSameValue(
+ realpath($expectedFile),
+ realpath((string)$reflection->getFileName()),
+ "{$name} should be loaded from {$expectedFile}"
+ );
+}
+
+/**
+ * @param null|string|list $migration
+ */
+function createPluginPackage(string $root, string $code, string $name, string $service, null|string|array $migration = null, array $publish = []): void
+{
+ $path = "{$root}/plugin/{$code}";
+ mkdir($path, 0777, true);
+ $extra = [
+ 'think' => ['services' => [$service]],
+ 'xadmin' => ['app' => ['code' => $code, 'name' => ucfirst($code)]],
+ ];
+ if ($publish !== []) {
+ $extra['xadmin']['publish'] = ['copy' => $publish];
+ }
+ file_put_contents($path . '/composer.json', json_encode([
+ 'name' => $name,
+ 'type' => 'think-admin-plugin',
+ 'version' => '1.0.0',
+ 'description' => ucfirst($code),
+ 'extra' => $extra,
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+
+ if ($migration) {
+ mkdir($path . '/stc/database', 0777, true);
+ foreach ((array)$migration as $filename) {
+ file_put_contents($path . '/stc/database/' . $filename, "getPathname(), 0777);
+ if ($item->isDir()) {
+ @rmdir($item->getPathname());
+ } else {
+ @unlink($item->getPathname());
+ }
+ }
+
+ @rmdir($path);
+}
+
+function copyTree(string $source, string $target): void
+{
+ if (!is_dir($source)) {
+ return;
+ }
+
+ mkdir($target, 0777, true);
+ $items = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ foreach ($items as $item) {
+ $relative = substr($item->getPathname(), strlen($source) + 1);
+ $pathname = $target . '/' . $relative;
+ if (is_link($item->getPathname())) {
+ $real = realpath($item->getPathname());
+ if ($real === false) {
+ continue;
+ }
+ if (is_dir($real)) {
+ copyTree($real, $pathname);
+ } else {
+ copyFile($real, $pathname);
+ }
+ continue;
+ }
+ if ($item->isDir()) {
+ is_dir($pathname) || mkdir($pathname, 0777, true);
+ } else {
+ copyFile($item->getPathname(), $pathname);
+ }
+ }
+}
+
+function copyFile(string $source, string $target): void
+{
+ $real = realpath($source);
+ if ($real !== false && is_dir($real)) {
+ copyTree($real, $target);
+ return;
+ }
+ if ($real !== false && is_file($real)) {
+ $source = $real;
+ }
+ if (is_dir($source)) {
+ copyTree($source, $target);
+ return;
+ }
+ is_dir(dirname($target)) || mkdir(dirname($target), 0777, true);
+ copy($source, $target);
+}
+
+function assertTrue(bool $condition, string $message): void
+{
+ if ($condition) {
+ return;
+ }
+
+ throw new RuntimeException($message);
+}
+
+function assertSameValue($expected, $actual, string $message): void
+{
+ if ($expected === $actual) {
+ return;
+ }
+
+ throw new RuntimeException(
+ $message . ' | expected: ' . var_export($expected, true) . ' actual: ' . var_export($actual, true)
+ );
+}
+
+function writeLine(string $message): void
+{
+ fwrite(STDOUT, $message . PHP_EOL);
+}
diff --git a/tests/support/SqliteIntegrationTestCase.php b/tests/support/SqliteIntegrationTestCase.php
new file mode 100644
index 000000000..61e9a468a
--- /dev/null
+++ b/tests/support/SqliteIntegrationTestCase.php
@@ -0,0 +1,1452 @@
+resetProcessState();
+
+ $this->sandboxPath = sys_get_temp_dir() . '/thinkadmin-sqlite-' . md5(static::class . microtime(true) . uniqid('', true));
+ $this->databaseFile = $this->sandboxPath . '/database.sqlite';
+ $this->connectionName = 'sqlite_' . md5($this->sandboxPath);
+
+ if (!is_dir($this->sandboxPath)) {
+ mkdir($this->sandboxPath, 0777, true);
+ }
+
+ touch($this->databaseFile);
+ $this->bootApplication();
+ $this->defineSchema();
+ $this->afterSchemaCreated();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->resetProcessState();
+ $this->restoreAccountTypes();
+ RequestContext::clear();
+ function_exists('sysvar') && sysvar('', '');
+ Container::getInstance()->instance(SystemContextInterface::class, new NullSystemContext());
+
+ if (isset($this->app)) {
+ $this->closeDatabaseConnections();
+ }
+
+ $this->removeDirectory($this->sandboxPath);
+ parent::tearDown();
+ }
+
+ abstract protected function defineSchema(): void;
+
+ protected function afterSchemaCreated(): void {}
+
+ protected function connection(): ConnectionInterface
+ {
+ return $this->app->db->connect($this->connectionName);
+ }
+
+ protected function executeStatements(array $statements): void
+ {
+ $connection = $this->connection();
+ foreach ($statements as $statement) {
+ $connection->execute($statement);
+ }
+ }
+
+ protected function configureView(array $overrides = []): void
+ {
+ $config = [
+ 'type' => 'Think',
+ 'auto_rule' => 1,
+ 'view_dir_name' => 'view',
+ 'view_path' => '',
+ 'view_suffix' => 'html',
+ 'view_depr' => DIRECTORY_SEPARATOR,
+ 'tpl_cache' => false,
+ 'tpl_begin' => '{',
+ 'tpl_end' => '}',
+ 'taglib_begin' => '{',
+ 'taglib_end' => '}',
+ 'strip_space' => true,
+ 'default_filter' => 'htmlentities=###,ENT_QUOTES',
+ ];
+
+ $configFile = TEST_PROJECT_ROOT . '/config/view.php';
+ if (is_file($configFile)) {
+ $config = array_merge($config, include $configFile);
+ }
+
+ $this->app->config->set(array_merge($config, $overrides), 'view');
+ }
+
+ protected function activateApplicationContext(?Request $request = null): void
+ {
+ Container::setInstance($this->app);
+ Library::$sapp = $this->app;
+ if ($request instanceof Request) {
+ $this->app->instance('request', $request);
+ Container::getInstance()->instance('request', $request);
+ }
+ }
+
+ protected function createAccountTables(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_account_user (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT DEFAULT '',
+ phone TEXT DEFAULT '',
+ email TEXT DEFAULT '',
+ unionid TEXT DEFAULT '',
+ username TEXT DEFAULT '',
+ nickname TEXT DEFAULT '',
+ password TEXT DEFAULT '',
+ headimg TEXT DEFAULT '',
+ region_prov TEXT DEFAULT '',
+ region_city TEXT DEFAULT '',
+ region_area TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ extra TEXT DEFAULT '',
+ sort INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ <<<'SQL'
+CREATE TABLE plugin_account_bind (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ type TEXT DEFAULT '',
+ phone TEXT DEFAULT '',
+ appid TEXT DEFAULT '',
+ openid TEXT DEFAULT '',
+ unionid TEXT DEFAULT '',
+ headimg TEXT DEFAULT '',
+ nickname TEXT DEFAULT '',
+ password TEXT DEFAULT '',
+ extra TEXT DEFAULT '',
+ sort INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ <<<'SQL'
+CREATE TABLE plugin_account_auth (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ usid INTEGER DEFAULT 0,
+ time INTEGER DEFAULT 0,
+ type TEXT DEFAULT '',
+ token TEXT DEFAULT '',
+ tokenv TEXT DEFAULT '',
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createPaymentBalanceTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_payment_balance (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ code TEXT DEFAULT '',
+ source_type TEXT DEFAULT '',
+ source_id TEXT DEFAULT '',
+ name TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ amount NUMERIC DEFAULT 0.00,
+ amount_prev NUMERIC DEFAULT 0.00,
+ amount_next NUMERIC DEFAULT 0.00,
+ cancel INTEGER DEFAULT 0,
+ unlock INTEGER DEFAULT 0,
+ create_by INTEGER DEFAULT 0,
+ cancel_time TEXT DEFAULT NULL,
+ unlock_time TEXT DEFAULT NULL,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createPaymentIntegralTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_payment_integral (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ code TEXT DEFAULT '',
+ source_type TEXT DEFAULT '',
+ source_id TEXT DEFAULT '',
+ name TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ amount NUMERIC DEFAULT 0.00,
+ amount_prev NUMERIC DEFAULT 0.00,
+ amount_next NUMERIC DEFAULT 0.00,
+ cancel INTEGER DEFAULT 0,
+ unlock INTEGER DEFAULT 0,
+ create_by INTEGER DEFAULT 0,
+ cancel_time TEXT DEFAULT NULL,
+ unlock_time TEXT DEFAULT NULL,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createPaymentRecordTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_payment_record (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ usid INTEGER DEFAULT 0,
+ code TEXT DEFAULT '',
+ order_no TEXT DEFAULT '',
+ order_name TEXT DEFAULT '',
+ order_amount NUMERIC DEFAULT 0.00,
+ channel_type TEXT DEFAULT '',
+ channel_code TEXT DEFAULT '',
+ payment_time TEXT DEFAULT NULL,
+ payment_trade TEXT DEFAULT '',
+ payment_status INTEGER DEFAULT 0,
+ payment_amount NUMERIC DEFAULT 0.00,
+ payment_coupon NUMERIC DEFAULT 0.00,
+ payment_images TEXT DEFAULT '',
+ payment_remark TEXT DEFAULT '',
+ payment_notify TEXT DEFAULT '',
+ audit_user INTEGER DEFAULT 0,
+ audit_time TEXT DEFAULT NULL,
+ audit_status INTEGER DEFAULT 1,
+ audit_remark TEXT DEFAULT '',
+ refund_status INTEGER DEFAULT 0,
+ refund_amount NUMERIC DEFAULT 0.00,
+ refund_payment NUMERIC DEFAULT 0.00,
+ refund_balance NUMERIC DEFAULT 0.00,
+ refund_integral NUMERIC DEFAULT 0.00,
+ used_payment NUMERIC DEFAULT 0.00,
+ used_balance NUMERIC DEFAULT 0.00,
+ used_integral NUMERIC DEFAULT 0.00,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemFileTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_file (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT DEFAULT '',
+ hash TEXT DEFAULT '',
+ tags TEXT DEFAULT '',
+ name TEXT DEFAULT '',
+ extension TEXT DEFAULT '',
+ xext TEXT DEFAULT '',
+ file_url TEXT DEFAULT '',
+ xurl TEXT DEFAULT '',
+ storage_key TEXT DEFAULT '',
+ xkey TEXT DEFAULT '',
+ mime TEXT DEFAULT '',
+ size INTEGER DEFAULT 0,
+ system_user_id INTEGER DEFAULT 0,
+ uuid INTEGER DEFAULT 0,
+ biz_user_id INTEGER DEFAULT 0,
+ unid INTEGER DEFAULT 0,
+ is_fast_upload INTEGER DEFAULT 0,
+ isfast INTEGER DEFAULT 0,
+ is_safe INTEGER DEFAULT 0,
+ issafe INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemBaseTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_base (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT DEFAULT '',
+ code TEXT DEFAULT '',
+ name TEXT DEFAULT '',
+ content TEXT DEFAULT '',
+ text_value TEXT DEFAULT '',
+ meta_json TEXT DEFAULT '',
+ sort INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ delete_time TEXT DEFAULT NULL,
+ deleted_by INTEGER DEFAULT 0,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemUserTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_user (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ base_code TEXT DEFAULT '',
+ username TEXT DEFAULT '',
+ password TEXT DEFAULT '',
+ nickname TEXT DEFAULT '',
+ headimg TEXT DEFAULT '',
+ auth_ids TEXT DEFAULT '',
+ contact_qq TEXT DEFAULT '',
+ contact_mail TEXT DEFAULT '',
+ contact_phone TEXT DEFAULT '',
+ login_ip TEXT DEFAULT '',
+ login_at TEXT DEFAULT '',
+ login_num INTEGER DEFAULT 0,
+ remark TEXT DEFAULT '',
+ sort INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ delete_time TEXT DEFAULT NULL,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemAuthTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_auth (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT DEFAULT '',
+ code TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ sort INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ create_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemAuthNodeTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_auth_node (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ auth INTEGER DEFAULT 0,
+ node TEXT DEFAULT ''
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemMenuTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_menu (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ pid INTEGER DEFAULT 0,
+ title TEXT DEFAULT '',
+ icon TEXT DEFAULT '',
+ node TEXT DEFAULT '',
+ url TEXT DEFAULT '',
+ params TEXT DEFAULT '',
+ target TEXT DEFAULT '_self',
+ sort INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ create_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemConfigTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_config (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT DEFAULT '',
+ name TEXT DEFAULT '',
+ value TEXT DEFAULT ''
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemDataTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_data (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT DEFAULT '',
+ value TEXT DEFAULT '',
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemOplogTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_oplog (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ node TEXT DEFAULT '',
+ request_ip TEXT DEFAULT '',
+ geoip TEXT DEFAULT '',
+ action TEXT DEFAULT '',
+ content TEXT DEFAULT '',
+ username TEXT DEFAULT '',
+ create_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createSystemQueueTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE system_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT DEFAULT '',
+ exec_hash TEXT DEFAULT '',
+ title TEXT DEFAULT '',
+ command TEXT DEFAULT '',
+ exec_pid INTEGER DEFAULT 0,
+ exec_data TEXT DEFAULT '',
+ exec_time INTEGER DEFAULT 0,
+ exec_desc TEXT DEFAULT '',
+ enter_time NUMERIC DEFAULT 0.0000,
+ outer_time NUMERIC DEFAULT 0.0000,
+ loops_time INTEGER DEFAULT 0,
+ attempts INTEGER DEFAULT 0,
+ message TEXT DEFAULT '',
+ status INTEGER DEFAULT 1,
+ create_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createPaymentAddressTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_payment_address (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ type INTEGER DEFAULT 0,
+ user_name TEXT DEFAULT '',
+ user_phone TEXT DEFAULT '',
+ idcode TEXT DEFAULT '',
+ idimg1 TEXT DEFAULT '',
+ idimg2 TEXT DEFAULT '',
+ region_prov TEXT DEFAULT '',
+ region_city TEXT DEFAULT '',
+ region_area TEXT DEFAULT '',
+ region_addr TEXT DEFAULT '',
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createPaymentRefundTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_payment_refund (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ usid INTEGER DEFAULT 0,
+ code TEXT DEFAULT '',
+ record_code TEXT DEFAULT '',
+ refund_time TEXT DEFAULT NULL,
+ refund_trade TEXT DEFAULT '',
+ refund_status INTEGER DEFAULT 0,
+ refund_amount NUMERIC DEFAULT 0.00,
+ refund_account TEXT DEFAULT '',
+ refund_scode TEXT DEFAULT '',
+ refund_remark TEXT DEFAULT '',
+ refund_notify TEXT DEFAULT '',
+ used_payment NUMERIC DEFAULT 0.00,
+ used_balance NUMERIC DEFAULT 0.00,
+ used_integral NUMERIC DEFAULT 0.00,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallOrderTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_order (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ puid1 INTEGER DEFAULT 0,
+ puid2 INTEGER DEFAULT 0,
+ puid3 INTEGER DEFAULT 0,
+ order_no TEXT DEFAULT '',
+ status INTEGER DEFAULT 2,
+ delivery_type INTEGER DEFAULT 1,
+ payment_status INTEGER DEFAULT 0,
+ refund_status INTEGER DEFAULT 0,
+ cancel_status INTEGER DEFAULT 0,
+ deleted_status INTEGER DEFAULT 0,
+ amount_goods NUMERIC DEFAULT 0.00,
+ amount_discount NUMERIC DEFAULT 0.00,
+ amount_reduct NUMERIC DEFAULT 0.00,
+ amount_express NUMERIC DEFAULT 0.00,
+ amount_real NUMERIC DEFAULT 0.00,
+ amount_total NUMERIC DEFAULT 0.00,
+ payment_amount NUMERIC DEFAULT 0.00,
+ amount_payment NUMERIC DEFAULT 0.00,
+ amount_balance NUMERIC DEFAULT 0.00,
+ amount_integral NUMERIC DEFAULT 0.00,
+ rebate_amount NUMERIC DEFAULT 0.00,
+ reward_balance NUMERIC DEFAULT 0.00,
+ reward_integral NUMERIC DEFAULT 0.00,
+ level_agent INTEGER DEFAULT 0,
+ level_member INTEGER DEFAULT 0,
+ payment_time TEXT DEFAULT NULL,
+ deleted_remark TEXT DEFAULT '',
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallOrderItemTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_order_item (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ ssid INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ level_code INTEGER DEFAULT 0,
+ level_agent INTEGER DEFAULT 0,
+ level_upgrade INTEGER DEFAULT 0,
+ discount_id INTEGER DEFAULT 0,
+ rebate_type INTEGER DEFAULT 0,
+ stock_sales INTEGER DEFAULT 0,
+ delivery_count INTEGER DEFAULT 0,
+ order_no TEXT DEFAULT '',
+ gcode TEXT DEFAULT '',
+ ghash TEXT DEFAULT '',
+ gname TEXT DEFAULT '',
+ gcover TEXT DEFAULT '',
+ gunit TEXT DEFAULT '',
+ gspec TEXT DEFAULT '',
+ gsku TEXT DEFAULT '',
+ level_name TEXT DEFAULT '',
+ delivery_code TEXT DEFAULT '',
+ price_market NUMERIC DEFAULT 0.00,
+ price_selling NUMERIC DEFAULT 0.00,
+ amount_cost NUMERIC DEFAULT 0.00,
+ total_price_market NUMERIC DEFAULT 0.00,
+ total_price_selling NUMERIC DEFAULT 0.00,
+ total_price_cost NUMERIC DEFAULT 0.00,
+ discount_rate NUMERIC DEFAULT 0.00,
+ discount_amount NUMERIC DEFAULT 0.00,
+ total_allow_balance NUMERIC DEFAULT 0.00,
+ total_allow_integral NUMERIC DEFAULT 0.00,
+ rebate_amount NUMERIC DEFAULT 0.00,
+ total_reward_balance NUMERIC DEFAULT 0.00,
+ total_reward_integral NUMERIC DEFAULT 0.00,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallOrderSenderTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_order_sender (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ ssid INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ delivery_count INTEGER DEFAULT 0,
+ address_id TEXT DEFAULT '',
+ order_no TEXT DEFAULT '',
+ delivery_code TEXT DEFAULT '',
+ delivery_remark TEXT DEFAULT '',
+ company_code TEXT DEFAULT '',
+ company_name TEXT DEFAULT '',
+ express_code TEXT DEFAULT '',
+ express_remark TEXT DEFAULT '',
+ express_time TEXT DEFAULT NULL,
+ user_name TEXT DEFAULT '',
+ user_phone TEXT DEFAULT '',
+ user_idcode TEXT DEFAULT '',
+ user_idimg1 TEXT DEFAULT '',
+ user_idimg2 TEXT DEFAULT '',
+ region_prov TEXT DEFAULT '',
+ region_city TEXT DEFAULT '',
+ region_area TEXT DEFAULT '',
+ region_addr TEXT DEFAULT '',
+ extra TEXT DEFAULT '',
+ delivery_amount NUMERIC DEFAULT 0.00,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallUserRebateTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_user_rebate (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ order_unid INTEGER DEFAULT 0,
+ layer INTEGER DEFAULT 1,
+ status INTEGER DEFAULT 0,
+ code TEXT DEFAULT '',
+ hash TEXT DEFAULT '',
+ name TEXT DEFAULT '',
+ type TEXT DEFAULT '',
+ date TEXT DEFAULT '',
+ order_no TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ amount NUMERIC DEFAULT 0.00,
+ order_amount NUMERIC DEFAULT 0.00,
+ confirm_time TEXT DEFAULT NULL,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallUserTransferTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_user_transfer (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 0,
+ code TEXT DEFAULT '',
+ type TEXT DEFAULT '',
+ amount NUMERIC DEFAULT 0.00,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallConfigLevelTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_config_level (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ number INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ upgrade_team INTEGER DEFAULT 0,
+ upgrade_type INTEGER DEFAULT 0,
+ utime INTEGER DEFAULT 0,
+ name TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ cardbg TEXT DEFAULT '',
+ cover TEXT DEFAULT '',
+ extra TEXT DEFAULT '',
+ delete_time TEXT DEFAULT NULL,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallConfigAgentTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_config_agent (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ number INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ upgrade_type INTEGER DEFAULT 0,
+ utime INTEGER DEFAULT 0,
+ name TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ cardbg TEXT DEFAULT '',
+ cover TEXT DEFAULT '',
+ extra TEXT DEFAULT '',
+ delete_time TEXT DEFAULT NULL,
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallUserCreateTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_user_create (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ status INTEGER DEFAULT 1,
+ agent_entry INTEGER DEFAULT 0,
+ phone TEXT DEFAULT '',
+ name TEXT DEFAULT '',
+ password TEXT DEFAULT '',
+ headimg TEXT DEFAULT '',
+ agent_phone TEXT DEFAULT '',
+ rebate_total NUMERIC DEFAULT 0.00,
+ rebate_usable NUMERIC DEFAULT 0.00,
+ rebate_total_code TEXT DEFAULT '',
+ rebate_total_desc TEXT DEFAULT '',
+ rebate_usable_code TEXT DEFAULT '',
+ rebate_usable_desc TEXT DEFAULT '',
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function createWemallUserRelationTable(): void
+ {
+ $this->executeStatements([
+ <<<'SQL'
+CREATE TABLE plugin_wemall_user_relation (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ unid INTEGER DEFAULT 0,
+ layer INTEGER DEFAULT 0,
+ puid1 INTEGER DEFAULT 0,
+ puid2 INTEGER DEFAULT 0,
+ puid3 INTEGER DEFAULT 0,
+ puids INTEGER DEFAULT 0,
+ level_code INTEGER DEFAULT 0,
+ agent_level_code INTEGER DEFAULT 0,
+ entry_agent INTEGER DEFAULT 0,
+ entry_member INTEGER DEFAULT 0,
+ agent_state INTEGER DEFAULT 0,
+ agent_uuid INTEGER DEFAULT 0,
+ sort INTEGER DEFAULT 0,
+ path TEXT DEFAULT '',
+ level_name TEXT DEFAULT '',
+ agent_level_name TEXT DEFAULT '',
+ extra TEXT DEFAULT '',
+ create_time TEXT DEFAULT NULL,
+ update_time TEXT DEFAULT NULL,
+ delete_time TEXT DEFAULT NULL
+)
+SQL,
+ ]);
+ }
+
+ protected function decimal($value): string
+ {
+ return number_format((float)$value, 2, '.', '');
+ }
+
+ protected function configureAccountAccess(array $overrides = []): void
+ {
+ $this->rememberAccountTypes();
+ $this->restoreAccountTypes();
+ $this->context->setData('plugin.account.access', array_merge([
+ 'expire' => 3600,
+ 'headimg' => 'https://example.com/default-account.png',
+ 'userPrefix' => '测试账号',
+ ], $overrides));
+ }
+
+ protected function createAccountUser(array $overrides = []): PluginAccountUser
+ {
+ $user = PluginAccountUser::mk();
+ $user->save(array_merge([
+ 'code' => 'U' . random_int(100000, 999999),
+ 'phone' => $this->randomPhone(),
+ 'username' => 'user-' . random_int(100, 999),
+ 'nickname' => '测试用户',
+ 'extra' => [],
+ ], $overrides));
+
+ return $user->refresh();
+ }
+
+ protected function createAccountFixture(string $type = Account::WAP, array $data = []): AccountInterface
+ {
+ $field = Account::field($type) ?: 'phone';
+ $value = strval($data[$field] ?? $this->makeAccountIdentity($field));
+
+ $account = Account::mk($type);
+ $account->set(array_merge([$field => $value], $data), false);
+
+ return $account;
+ }
+
+ protected function createBoundAccountFixture(string $type = Account::WAP, array $bindData = [], array $userData = []): AccountInterface
+ {
+ $field = Account::field($type) ?: 'phone';
+ $account = $this->createAccountFixture($type, $bindData);
+ $current = $account->get();
+ $identity = strval($current[$field] ?? $bindData[$field] ?? '');
+
+ $map = isset($userData['id']) ? ['id' => intval($userData['id'])] : [$field => strval($userData[$field] ?? $identity)];
+ $payload = array_merge([
+ 'username' => 'user-' . random_int(100, 999),
+ ], $userData);
+
+ if (!isset($payload[$field]) && isset($map[$field])) {
+ $payload[$field] = $map[$field];
+ }
+
+ $account->bind($map, $payload);
+ return $account;
+ }
+
+ protected function createPaidEmptyOrderFixture(
+ string $orderNo,
+ ?AccountInterface $account = null,
+ array $overrides = []
+ ): PluginPaymentRecord {
+ $account ??= $this->createBoundAccountFixture();
+ $response = Payment::mk(Payment::EMPTY)->create(
+ $account,
+ $orderNo,
+ strval($overrides['title'] ?? '退款测试订单'),
+ strval($overrides['order_amount'] ?? '10.00'),
+ strval($overrides['pay_amount'] ?? '10.00'),
+ strval($overrides['remark'] ?? '退款边界测试')
+ );
+
+ return PluginPaymentRecord::mk()->where(['code' => $response->record['code']])->findOrEmpty();
+ }
+
+ protected function createWemallOrderFixture(AccountInterface $account, array $overrides = []): PluginWemallOrder
+ {
+ $order = PluginWemallOrder::mk();
+ $order->save(array_merge([
+ 'unid' => $account->getUnid(),
+ 'order_no' => 'ORDER-' . strtoupper(substr(md5(uniqid('', true)), 0, 10)),
+ 'status' => 2,
+ 'delivery_type' => 1,
+ 'payment_status' => 0,
+ 'refund_status' => 0,
+ 'cancel_status' => 0,
+ 'deleted_status' => 0,
+ 'puid1' => 0,
+ 'puid2' => 0,
+ 'puid3' => 0,
+ 'amount_goods' => '10.00',
+ 'amount_discount' => '10.00',
+ 'amount_reduct' => '0.00',
+ 'amount_express' => '0.00',
+ 'amount_real' => '10.00',
+ 'amount_total' => '10.00',
+ 'payment_amount' => '0.00',
+ 'amount_payment' => '0.00',
+ 'amount_balance' => '0.00',
+ 'amount_integral' => '0.00',
+ 'rebate_amount' => '0.00',
+ 'reward_balance' => '0.00',
+ 'reward_integral' => '0.00',
+ 'level_agent' => 0,
+ 'level_member' => 0,
+ ], $overrides));
+
+ return $order->refresh();
+ }
+
+ protected function createPaymentAddressFixture(int $unid, array $overrides = []): PluginPaymentAddress
+ {
+ $address = PluginPaymentAddress::mk();
+ $address->save(array_merge([
+ 'unid' => $unid,
+ 'type' => 1,
+ 'delete_time' => null,
+ 'user_name' => '测试收货人',
+ 'user_phone' => $this->randomPhone('1380013'),
+ 'idcode' => '110101199001010011',
+ 'idimg1' => '/upload/idcard-front.png',
+ 'idimg2' => '/upload/idcard-back.png',
+ 'region_prov' => '广东省',
+ 'region_city' => '深圳市',
+ 'region_area' => '南山区',
+ 'region_addr' => '科技园 1 号',
+ ], $overrides));
+
+ return $address->refresh();
+ }
+
+ protected function createSystemFileFixture(array $overrides = []): SystemFile
+ {
+ $file = SystemFile::mk();
+ $file->save(SystemFile::syncPayload(array_merge([
+ 'type' => 'local',
+ 'hash' => md5(uniqid('file', true)),
+ 'tags' => '',
+ 'name' => 'test-file.png',
+ 'extension' => 'png',
+ 'xext' => 'png',
+ 'file_url' => 'https://example.com/upload/test-file.png',
+ 'xurl' => 'https://example.com/upload/test-file.png',
+ 'storage_key' => 'upload/test-file.png',
+ 'xkey' => 'upload/test-file.png',
+ 'mime' => 'image/png',
+ 'size' => 1024,
+ 'system_user_id' => 0,
+ 'uuid' => 0,
+ 'biz_user_id' => 0,
+ 'unid' => 0,
+ 'is_fast_upload' => 0,
+ 'isfast' => 0,
+ 'is_safe' => 0,
+ 'issafe' => 0,
+ 'status' => 2,
+ 'create_time' => date('Y-m-d H:i:s'),
+ 'update_time' => date('Y-m-d H:i:s'),
+ ], $overrides)));
+
+ return $file->refresh();
+ }
+
+ protected function createSystemBaseFixture(array $overrides = []): SystemBase
+ {
+ $base = SystemBase::mk();
+ $base->save(array_merge([
+ 'type' => 'identity',
+ 'code' => 'base-' . random_int(1000, 9999),
+ 'name' => '测试字典',
+ 'content' => '',
+ 'text_value' => '',
+ 'meta_json' => '',
+ 'sort' => 0,
+ 'status' => 1,
+ 'delete_time' => null,
+ 'deleted_by' => 0,
+ 'create_time' => date('Y-m-d H:i:s'),
+ 'update_time' => date('Y-m-d H:i:s'),
+ ], $overrides));
+
+ return $base->refresh();
+ }
+
+ protected function createSystemUserFixture(array $overrides = []): SystemUser
+ {
+ $data = array_merge([
+ 'base_code' => '',
+ 'username' => 'admin-' . random_int(1000, 9999),
+ 'password' => $this->hashSystemPassword('123456'),
+ 'nickname' => '测试管理员',
+ 'headimg' => '',
+ 'auth_ids' => '',
+ 'contact_qq' => '',
+ 'contact_mail' => '',
+ 'contact_phone' => '',
+ 'login_ip' => '127.0.0.1',
+ 'login_at' => '',
+ 'login_num' => 0,
+ 'remark' => '',
+ 'sort' => 0,
+ 'status' => 1,
+ 'delete_time' => null,
+ 'create_time' => date('Y-m-d H:i:s'),
+ 'update_time' => date('Y-m-d H:i:s'),
+ ], $overrides);
+ $user = SystemUser::mk();
+ if (isset($data['id']) && is_numeric($data['id'])) {
+ $user = SystemUser::mk()->withTrashed()->findOrEmpty(intval($data['id']));
+ }
+ $user->save($data);
+
+ return $user->refresh();
+ }
+
+ protected function hashSystemPassword(string $password): string
+ {
+ return UserService::hashPassword($password);
+ }
+
+ protected function verifySystemPassword(string $password, ?string $hash): bool
+ {
+ return UserService::verifyPassword($password, $hash);
+ }
+
+ protected function createSystemAuthFixture(array $overrides = []): SystemAuth
+ {
+ $auth = SystemAuth::mk();
+ $auth->save(array_merge([
+ 'title' => '测试权限',
+ 'code' => 'auth-' . random_int(1000, 9999),
+ 'remark' => '测试说明',
+ 'sort' => 0,
+ 'status' => 1,
+ 'create_time' => date('Y-m-d H:i:s'),
+ ], $overrides));
+
+ return $auth->refresh();
+ }
+
+ protected function createSystemAuthNodeFixture(array $overrides = []): SystemNode
+ {
+ $node = SystemNode::mk();
+ $node->save(array_merge([
+ 'auth' => 0,
+ 'node' => 'index/test/index',
+ ], $overrides));
+
+ return $node->refresh();
+ }
+
+ protected function createSystemMenuFixture(array $overrides = []): SystemMenu
+ {
+ $menu = SystemMenu::mk();
+ $menu->save(array_merge([
+ 'pid' => 0,
+ 'title' => '测试菜单',
+ 'icon' => 'layui-icon layui-icon-set',
+ 'node' => '',
+ 'url' => '#',
+ 'params' => '',
+ 'target' => '_self',
+ 'sort' => 0,
+ 'status' => 1,
+ 'create_time' => date('Y-m-d H:i:s'),
+ ], $overrides));
+
+ return $menu->refresh();
+ }
+
+ protected function createSystemConfigFixture(array $overrides = []): SystemConfig
+ {
+ $config = SystemConfig::mk();
+ $config->save(array_merge([
+ 'type' => 'base',
+ 'name' => 'site_name',
+ 'value' => 'ThinkAdmin',
+ ], $overrides));
+
+ return $config->refresh();
+ }
+
+ protected function createSystemDataFixture(array $overrides = []): SystemData
+ {
+ $data = SystemData::mk();
+ $data->save(array_merge([
+ 'name' => 'TestDataKey',
+ 'value' => json_encode([['ok' => true]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'create_time' => date('Y-m-d H:i:s'),
+ 'update_time' => date('Y-m-d H:i:s'),
+ ], $overrides));
+ $this->flushSystemDataCache();
+
+ return $data->refresh();
+ }
+
+ protected function createSystemOplogFixture(array $overrides = []): SystemOplog
+ {
+ $oplog = SystemOplog::mk();
+ $oplog->save(SystemOplog::syncPayload(array_merge([
+ 'node' => 'system/test/index',
+ 'request_ip' => '',
+ 'geoip' => '127.0.0.1',
+ 'action' => '测试行为',
+ 'content' => '测试内容',
+ 'username' => 'tester',
+ 'create_time' => date('Y-m-d H:i:s'),
+ ], $overrides)));
+
+ return $oplog->refresh();
+ }
+
+ protected function createSystemQueueFixture(array $overrides = []): SystemQueue
+ {
+ $data = array_merge([
+ 'code' => 'Q' . strtoupper(substr(md5(uniqid('', true)), 0, 15)),
+ 'title' => '测试任务',
+ 'command' => 'xadmin:test queue',
+ 'exec_pid' => 0,
+ 'exec_data' => '{}',
+ 'exec_time' => time(),
+ 'exec_desc' => '',
+ 'enter_time' => '0.0000',
+ 'outer_time' => '0.0000',
+ 'loops_time' => 0,
+ 'attempts' => 0,
+ 'message' => '',
+ 'status' => 1,
+ 'create_time' => date('Y-m-d H:i:s'),
+ ], $overrides);
+ $queue = SystemQueue::mk()->where(['code' => $data['code']])->findOrEmpty();
+ $queue->save($data);
+ $this->resetWorkerQueueServiceState();
+
+ return $queue->refresh();
+ }
+
+ protected function createWemallOrderItemFixture(AccountInterface $account, string $orderNo, array $overrides = []): PluginWemallOrderItem
+ {
+ $item = PluginWemallOrderItem::mk();
+ $item->save(array_merge([
+ 'unid' => $account->getUnid(),
+ 'status' => 1,
+ 'delete_time' => null,
+ 'order_no' => $orderNo,
+ 'delivery_count' => 1,
+ 'delivery_code' => 'NONE',
+ 'gcode' => 'GCODE-' . strtoupper(substr(md5(uniqid('', true)), 0, 8)),
+ 'ghash' => 'GHASH-' . strtoupper(substr(md5(uniqid('', true)), 0, 8)),
+ 'gname' => '测试商品',
+ 'gcover' => '/upload/goods.png',
+ 'gunit' => '件',
+ 'gspec' => '默认规格',
+ 'gsku' => 'SKU-001',
+ 'price_market' => '10.00',
+ 'price_selling' => '10.00',
+ 'amount_cost' => '5.00',
+ 'total_price_market' => '10.00',
+ 'total_price_selling' => '10.00',
+ 'total_price_cost' => '5.00',
+ 'discount_rate' => '100.00',
+ 'discount_amount' => '0.00',
+ 'total_allow_balance' => '0.00',
+ 'total_allow_integral' => '0.00',
+ 'rebate_amount' => '0.00',
+ 'total_reward_balance' => '0.00',
+ 'total_reward_integral' => '0.00',
+ ], $overrides));
+
+ return $item->refresh();
+ }
+
+ protected function createWemallRelationFixture(int $unid, array $overrides = []): PluginWemallUserRelation
+ {
+ $relation = PluginWemallUserRelation::mk();
+ $relation->save(array_merge([
+ 'unid' => $unid,
+ 'layer' => 0,
+ 'puid1' => 0,
+ 'puid2' => 0,
+ 'puid3' => 0,
+ 'puids' => 0,
+ 'level_code' => 0,
+ 'agent_level_code' => 0,
+ 'entry_agent' => 0,
+ 'entry_member' => 0,
+ 'path' => ',',
+ 'level_name' => '普通用户',
+ 'agent_level_name' => '会员用户',
+ 'extra' => [],
+ ], $overrides));
+
+ return $relation->refresh();
+ }
+
+ protected function randomPhone(string $prefix = '1360013'): string
+ {
+ return $prefix . random_int(1000, 9999);
+ }
+
+ private function bootApplication(): void
+ {
+ function_exists('test_reset_model_makers') && test_reset_model_makers();
+ $this->app = new App(TEST_PROJECT_ROOT);
+ RuntimeService::init($this->app);
+ $this->activateApplicationContext();
+
+ $this->app->config->set([
+ 'default' => 'file',
+ 'stores' => [
+ 'file' => [
+ 'type' => 'File',
+ 'path' => $this->sandboxPath . '/cache',
+ ],
+ ],
+ ], 'cache');
+ $this->app->config->set([
+ 'default' => 'file',
+ 'channels' => [
+ 'file' => [
+ 'type' => 'File',
+ 'path' => $this->sandboxPath . '/log',
+ 'single' => true,
+ 'apart_level' => [],
+ 'max_files' => 0,
+ 'json' => false,
+ 'format' => '[%s][%s] %s',
+ 'realtime_write' => true,
+ ],
+ ],
+ ], 'log');
+ $this->app->config->set(['jwtkey' => 'integration-test-jwt'], 'app');
+ $this->app->config->set([
+ 'default' => $this->connectionName,
+ 'auto_timestamp' => true,
+ 'datetime_format' => 'Y-m-d H:i:s',
+ 'connections' => [
+ $this->connectionName => [
+ 'type' => 'sqlite',
+ 'database' => $this->databaseFile,
+ 'charset' => 'utf8',
+ 'trigger_sql' => false,
+ 'deploy' => 0,
+ 'suffix' => '',
+ 'prefix' => '',
+ 'hostname' => '',
+ 'hostport' => '',
+ 'username' => '',
+ 'password' => '',
+ ],
+ ],
+ ], 'database');
+ $this->app->db->setConfig($this->app->config);
+ $this->app->db->connect($this->connectionName)->execute('PRAGMA busy_timeout = 5000');
+ ThinkModel::maker(function (ThinkModel $model): void {
+ $db = $this->app->db;
+ $model->setOption('db', $db);
+ if (is_null($model->getAutoWriteTimestamp())) {
+ $model->isAutoWriteTimestamp($db->getConfig('auto_timestamp', true));
+ }
+ if (is_null($model->getDateFormat())) {
+ $model->setDateFormat($db->getConfig('datetime_format', 'Y-m-d H:i:s'));
+ }
+ });
+
+ $this->context = new TestSystemContext();
+ if (interface_exists(StorageManagerInterface::class) && class_exists(StorageManager::class)) {
+ Container::getInstance()->bind(StorageManagerInterface::class, StorageManager::class);
+ }
+ Container::getInstance()->instance(SystemContextInterface::class, $this->context);
+ RequestContext::clear();
+ function_exists('sysvar') && sysvar('', '');
+ }
+
+ private function resetProcessState(): void
+ {
+ RequestContext::clear();
+ function_exists('sysvar') && sysvar('', '');
+ AppService::clear();
+ AuthService::removeCheckCallable(null);
+ $this->resetWorkerQueueServiceState();
+ }
+
+ private function flushSystemDataCache(): void
+ {
+ function_exists('sysvar') && sysvar('think.admin.data', []);
+ if (isset($this->app)) {
+ try {
+ $this->app->cache->delete('SystemData');
+ } catch (\Throwable) {
+ }
+ }
+ }
+
+ private function resetWorkerQueueServiceState(): void
+ {
+ try {
+ $property = new \ReflectionProperty(WorkerQueueService::class, 'tableFields');
+ $property->setAccessible(true);
+ $property->setValue(null, null);
+ } catch (\Throwable) {
+ }
+ }
+
+ private function rememberAccountTypes(): void
+ {
+ if ($this->accountTypesSnapshot !== null) {
+ return;
+ }
+
+ $this->accountTypesSnapshot = (new \ReflectionClass(Account::class))->getDefaultProperties()['types'];
+ }
+
+ private function restoreAccountTypes(): void
+ {
+ if ($this->accountTypesSnapshot === null) {
+ return;
+ }
+
+ $reflection = new \ReflectionClass(Account::class);
+
+ $types = $reflection->getProperty('types');
+ $types->setValue(null, $this->accountTypesSnapshot);
+
+ $denys = $reflection->getProperty('denys');
+ $denys->setValue(null, null);
+ }
+
+ private function makeAccountIdentity(string $field): string
+ {
+ if ($field === 'phone') {
+ return $this->randomPhone();
+ }
+
+ return $field . '-' . uniqid();
+ }
+
+ private function removeDirectory(string $path): void
+ {
+ if ($path === '' || !is_dir($path)) {
+ return;
+ }
+
+ foreach (scandir($path) ?: [] as $item) {
+ if ($item === '.' || $item === '..') {
+ continue;
+ }
+
+ $target = $path . DIRECTORY_SEPARATOR . $item;
+ if (is_dir($target)) {
+ $this->removeDirectory($target);
+ } elseif (is_file($target)) {
+ @unlink($target);
+ }
+ }
+
+ @rmdir($path);
+ }
+
+ private function closeDatabaseConnections(): void
+ {
+ try {
+ $manager = $this->app->db;
+ $reflection = new \ReflectionObject($manager);
+ if (!$reflection->hasProperty('instance')) {
+ return;
+ }
+
+ $property = $reflection->getProperty('instance');
+ $property->setAccessible(true);
+ foreach ((array)$property->getValue($manager) as $connection) {
+ if ($connection instanceof ConnectionInterface) {
+ try {
+ $connection->close();
+ } catch (\Throwable) {
+ }
+ }
+ }
+ $property->setValue($manager, []);
+ } catch (\Throwable) {
+ try {
+ $this->app->db->connect($this->connectionName)->close();
+ } catch (\Throwable) {
+ }
+ }
+ }
+}
diff --git a/tests/support/TestSystemContext.php b/tests/support/TestSystemContext.php
new file mode 100644
index 000000000..311735a2f
--- /dev/null
+++ b/tests/support/TestSystemContext.php
@@ -0,0 +1,141 @@
+super) {
+ return true;
+ }
+ return $node !== null && in_array($node, $this->nodes, true);
+ }
+
+ public function getUser(?string $field = null, $default = null)
+ {
+ if ($field === null) {
+ return $this->user;
+ }
+
+ return $this->user[$field] ?? $default;
+ }
+
+ public function getUserId(): int
+ {
+ return $this->userId;
+ }
+
+ public function isSuper(): bool
+ {
+ return $this->super;
+ }
+
+ public function isLogin(): bool
+ {
+ return $this->login;
+ }
+
+ public function withUploadUnid(?string $uptoken = null): array
+ {
+ return [0, []];
+ }
+
+ public function clearAuth(): bool
+ {
+ return true;
+ }
+
+ public function getData(string $name, $default = [])
+ {
+ return $this->data[$name] ?? $default;
+ }
+
+ public function setData(string $name, $value): bool
+ {
+ $this->data[$name] = $value;
+ return true;
+ }
+
+ public function setOplog(string $action, string $content): bool
+ {
+ return true;
+ }
+
+ public function baseItems(string $type, array &$data = [], string $field = 'base_code', string $bind = 'base_info'): array
+ {
+ return [];
+ }
+
+ public function setUser(array $user = [], bool $login = false, bool $super = false): self
+ {
+ $this->user = $user;
+ $this->userId = intval($user['id'] ?? 0);
+ $this->login = $login;
+ $this->super = $super;
+ return $this;
+ }
+
+ public function setNodes(array $nodes = []): self
+ {
+ $this->nodes = array_values(array_unique(array_filter(array_map('strval', $nodes))));
+ return $this;
+ }
+}