diff --git a/app/Core/Helper.php b/app/Core/Helper.php index 23f2945..9e2437d 100644 --- a/app/Core/Helper.php +++ b/app/Core/Helper.php @@ -167,6 +167,19 @@ if (!function_exists('site_url')) { if (!subsys_user('psi')) return 0; $uid = (int) ($_SESSION['admin_uid'] ?? 0); if ($uid <= 0) return 0; + // V0.9.3:MySQL 下改用 COUNT + JSON_CONTAINS 聚合,避免每个后台页都 SELECT * 全表拉回再循环计数。 + // 语义与旧逻辑一致:统计「当前用户不在 read_by 已读列表」的事件数。 + if (\Core\Db::driver() === 'mysql') { + try { + return (int) \Core\Db::query( + "SELECT COUNT(*) FROM psi_events WHERE JSON_CONTAINS(COALESCE(read_by,'[]'), CAST(? AS JSON)) = 0", + [$uid] + )->fetchColumn(); + } catch (\Throwable $e) { + // 极端情况(如 read_by 含非法 JSON)回退旧逻辑,保证铃铛数字不报错 + } + } + // 文件模式 / MySQL 兜底:无 JSON 函数或异常,保留全表循环(数据量小,可接受) try { $events = (new \App\Models\PSI\Event())->all(); } catch (\Throwable $e) { diff --git a/app/Core/Model.php b/app/Core/Model.php index c263041..deaa69d 100644 --- a/app/Core/Model.php +++ b/app/Core/Model.php @@ -11,6 +11,28 @@ class Model protected $pk = 'id'; protected $orderBy = 'id'; + /* ---------- 请求内缓存(同一请求内重复读取同一查询只走一次 DB/文件) ---------- */ + private static $reqCache = []; + + private function cacheKey(string $method, ...$args): string + { + return $this->table . '|' . $method . '|' . md5(serialize($args)); + } + /** 读取缓存;未命中返回 null(用 array_key_exists 区分「未缓存」与「缓存了空数组」) */ + private function cacheGet(string $k) + { + return array_key_exists($k, self::$reqCache) ? self::$reqCache[$k] : null; + } + private function cachePut(string $k, $v): void + { + self::$reqCache[$k] = $v; + } + /** 任意写操作后清空请求内缓存,避免同请求内读到脏数据(CMS 读多写少,刷新成本可忽略) */ + private function cacheFlush(): void + { + self::$reqCache = []; + } + /* ---------- 文件模式 ---------- */ private function file(): string { @@ -71,10 +93,16 @@ class Model /* ---------- 通用 CRUD ---------- */ public function all(): array { + $k = $this->cacheKey('all'); + $hit = $this->cacheGet($k); + if ($hit !== null) return $hit; if (Db::driver() === 'mysql') { - return Db::query("SELECT * FROM `{$this->table}` ORDER BY `{$this->orderBy}` ASC")->fetchAll(); + $rows = Db::query("SELECT * FROM `{$this->table}` ORDER BY `{$this->orderBy}` ASC")->fetchAll(); + } else { + $rows = $this->read(); $this->sort($rows); } - $rows = $this->read(); $this->sort($rows); return $rows; + $this->cachePut($k, $rows); + return $rows; } public function find($id) @@ -106,6 +134,7 @@ class Model public function insert(array $data) { + $this->cacheFlush(); if (Db::driver() === 'mysql') { $cols = array_keys($data); $sql = "INSERT INTO `{$this->table}` (`" . implode('`,`', $cols) . "`) VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")"; @@ -121,6 +150,7 @@ class Model public function update($id, array $data): void { + $this->cacheFlush(); if (Db::driver() === 'mysql') { $cols = array_keys($data); $sql = "UPDATE `{$this->table}` SET `" . implode('`=?,`', $cols) . "`=? WHERE `{$this->pk}`=?"; @@ -136,6 +166,7 @@ class Model public function delete($id): void { + $this->cacheFlush(); if (Db::driver() === 'mysql') { Db::query("DELETE FROM `{$this->table}` WHERE `{$this->pk}`=?", [$id]); return; @@ -147,6 +178,7 @@ class Model /** 按任意列批量删除(用于主从表级联删除从表) */ public function deleteRaw(string $col, $val): void { + $this->cacheFlush(); if (Db::driver() === 'mysql') { Db::query("DELETE FROM `{$this->table}` WHERE `{$col}`=?", [$val]); return; @@ -155,8 +187,42 @@ class Model $this->write(array_values($rows)); } - public function count(): int + /** + * 计数:MySQL 下走 COUNT(*) 聚合(避免 SELECT * 全表拉回再 count); + * 可选按某列过滤。文件模式下回退为本地计数。 + * @param string|null $col 过滤列(null 表示全表计数) + * @param mixed $val 过滤值 + */ + public function count(string $col = null, $val = null): int { - return count($this->all()); + if (Db::driver() === 'mysql') { + if ($col === null) { + return (int) Db::query("SELECT COUNT(*) FROM `{$this->table}`")->fetchColumn(); + } + return (int) Db::query("SELECT COUNT(*) FROM `{$this->table}` WHERE `{$col}`=?", [$val])->fetchColumn(); + } + $rows = $this->all(); + if ($col === null) return count($rows); + $n = 0; + foreach ($rows as $r) { + if (($r[$col] ?? null) == $val) $n++; + } + return $n; + } + + /** + * 取某列等于 $val 的前 $limit 条。 + * MySQL 下带 LIMIT(索引友好);文件模式用 whereAll + array_slice 兜底。 + * 用于首页精选 / 列表分页等高频只读场景。 + */ + public function whereLimit(string $col, $val, int $limit): array + { + if (Db::driver() === 'mysql') { + return Db::query( + "SELECT * FROM `{$this->table}` WHERE `{$col}`=? ORDER BY `{$this->orderBy}` ASC LIMIT ?", + [$val, $limit] + )->fetchAll(); + } + return array_slice($this->whereAll($col, $val), 0, $limit); } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 8c6c4ca..0dc34d1 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -14,7 +14,9 @@ class Product extends Model } public function featured(int $limit = 6): array { - return array_slice(array_filter($this->all(), fn($p) => ($p['status'] ?? 1) == 1), 0, $limit); + // V0.9.1:改用 whereLimit('status',1,$limit),MySQL 下走带 LIMIT 的索引查询, + // 不再 SELECT * 全表拉回再 array_filter(原写法每次请求都全表扫描)。 + return $this->whereLimit('status', 1, $limit); } public function specsArray($p): array { diff --git a/install/schema.sql b/install/schema.sql index 214599e..7776737 100644 --- a/install/schema.sql +++ b/install/schema.sql @@ -10,7 +10,8 @@ CREATE TABLE IF NOT EXISTS `categories` ( `sort_order` INT DEFAULT 0, `status` TINYINT DEFAULT 1, `layout` TEXT, - `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed' + `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed', + KEY idx_categories_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `products` ( @@ -29,7 +30,10 @@ CREATE TABLE IF NOT EXISTS `products` ( `status` TINYINT DEFAULT 1, `created_at` VARCHAR(20) DEFAULT '', `layout` TEXT, - `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed' + `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed', + KEY idx_products_category_id (category_id), + KEY idx_products_slug (slug), + KEY idx_products_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `news` ( @@ -44,7 +48,9 @@ CREATE TABLE IF NOT EXISTS `news` ( `status` TINYINT DEFAULT 1, `views` INT DEFAULT 0, `layout` TEXT, - `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed' + `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed', + KEY idx_news_slug (slug), + KEY idx_news_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `cases` ( @@ -61,7 +67,9 @@ CREATE TABLE IF NOT EXISTS `cases` ( `status` TINYINT DEFAULT 1, `views` INT DEFAULT 0, `layout` TEXT, - `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed' + `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed', + KEY idx_cases_slug (slug), + KEY idx_cases_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `pages` ( @@ -71,7 +79,8 @@ CREATE TABLE IF NOT EXISTS `pages` ( `content` TEXT, `layout` TEXT, `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed', - `updated_at` VARCHAR(20) DEFAULT '' + `updated_at` VARCHAR(20) DEFAULT '', + KEY idx_pages_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `banners` ( @@ -81,7 +90,9 @@ CREATE TABLE IF NOT EXISTS `banners` ( `image` VARCHAR(255) DEFAULT '', `link` VARCHAR(255) DEFAULT '', `sort_order` INT DEFAULT 0, - `status` TINYINT DEFAULT 1 + `status` TINYINT DEFAULT 1, + KEY idx_banners_status (status), + KEY idx_banners_sort (sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `admin_users` ( @@ -95,7 +106,8 @@ CREATE TABLE IF NOT EXISTS `admin_users` ( `crm_perms` TEXT, `psi_perms` TEXT, `status` TINYINT DEFAULT 1, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_admin_users_role (role) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /* ===================== CRM 客户管理系统 ===================== */ @@ -117,7 +129,8 @@ CREATE TABLE IF NOT EXISTS `crm_customers` ( `status` VARCHAR(20) DEFAULT 'lead', `remark` TEXT, `owner` VARCHAR(60) DEFAULT '', - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_customers_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `crm_contacts` ( @@ -130,7 +143,8 @@ CREATE TABLE IF NOT EXISTS `crm_contacts` ( `wechat` VARCHAR(60) DEFAULT '', `is_primary` TINYINT DEFAULT 0, `remark` TEXT, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_contacts_customer_id (customer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `crm_leads` ( @@ -144,7 +158,8 @@ CREATE TABLE IF NOT EXISTS `crm_leads` ( `probability` TINYINT DEFAULT 0, `owner` VARCHAR(60) DEFAULT '', `remark` TEXT, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_leads_customer_id (customer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `crm_followups` ( @@ -156,7 +171,9 @@ CREATE TABLE IF NOT EXISTS `crm_followups` ( `way` VARCHAR(20) DEFAULT '', `result` VARCHAR(60) DEFAULT '', `owner` VARCHAR(60) DEFAULT '', - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_followups_customer_id (customer_id), + KEY idx_followups_lead_id (lead_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; /* ===================== 进销存 PSI 系统 ===================== */ @@ -176,7 +193,9 @@ CREATE TABLE IF NOT EXISTS `psi_materials` ( `price` DECIMAL(10,2) DEFAULT 0, `supplier_id` INT DEFAULT 0, `remark` TEXT, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_materials_supplier_id (supplier_id), + KEY idx_materials_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `psi_products` ( @@ -195,7 +214,8 @@ CREATE TABLE IF NOT EXISTS `psi_products` ( `cost` DECIMAL(10,2) DEFAULT 0, `price` DECIMAL(10,2) DEFAULT 0, `remark` TEXT, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_psi_products_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `psi_suppliers` ( @@ -225,7 +245,9 @@ CREATE TABLE IF NOT EXISTS `psi_purchases` ( `batch_no` VARCHAR(40) DEFAULT '', `expected_at` VARCHAR(20) DEFAULT '', `remark` TEXT, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_purchases_supplier_id (supplier_id), + KEY idx_purchases_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `psi_sales` ( @@ -241,7 +263,8 @@ CREATE TABLE IF NOT EXISTS `psi_sales` ( `region` VARCHAR(40) DEFAULT '', `batch_no` VARCHAR(40) DEFAULT '', `remark` TEXT, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_sales_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `psi_stock_moves` ( @@ -253,14 +276,16 @@ CREATE TABLE IF NOT EXISTS `psi_stock_moves` ( `ref_no` VARCHAR(40) DEFAULT '', `batch_no` VARCHAR(40) DEFAULT '', `remark` TEXT, - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_stock_moves_item (item_type, item_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `settings` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `skey` VARCHAR(120) NOT NULL, `sval` TEXT, - `sgroup` VARCHAR(40) DEFAULT 'site' + `sgroup` VARCHAR(40) DEFAULT 'site', + KEY idx_settings_sgroup (sgroup) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `orders` ( @@ -277,7 +302,10 @@ CREATE TABLE IF NOT EXISTS `orders` ( `status` VARCHAR(20) DEFAULT 'pending', `gateway_trade_no` VARCHAR(120) DEFAULT '', `paid_at` VARCHAR(20) DEFAULT '', - `created_at` VARCHAR(20) DEFAULT '' + `created_at` VARCHAR(20) DEFAULT '', + KEY idx_orders_order_no (order_no), + KEY idx_orders_status (status), + KEY idx_orders_product_id (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `payments` ( @@ -289,7 +317,9 @@ CREATE TABLE IF NOT EXISTS `payments` ( `trade_no` VARCHAR(120) DEFAULT '', `status` VARCHAR(20) DEFAULT '', `created_at` VARCHAR(20) DEFAULT '', - `paid_at` VARCHAR(20) DEFAULT '' + `paid_at` VARCHAR(20) DEFAULT '', + KEY idx_payments_order_id (order_id), + KEY idx_payments_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `psi_events` ( @@ -304,5 +334,7 @@ CREATE TABLE IF NOT EXISTS `psi_events` ( `recipients` TEXT, `channels` VARCHAR(255) NOT NULL DEFAULT '["inapp"]', `read_by` TEXT, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_events_sys (sys), + KEY idx_events_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/install/upgrades/009_perf_indexes.sql b/install/upgrades/009_perf_indexes.sql new file mode 100644 index 0000000..fac9887 --- /dev/null +++ b/install/upgrades/009_perf_indexes.sql @@ -0,0 +1,55 @@ +-- V0.9.2 性能优化:为高频查询列补齐二级索引(MySQL 5.7 兼容) +-- 通过存储过程判断索引是否已存在,重复执行不会报错。 +-- 部署方式:放入 install/upgrades/ 后,后台「数据库升级」一键应用(仅超级管理员)。 +-- 升级记录会写入 db_upgrades,内容不变不会重复执行。 + +DROP PROCEDURE IF EXISTS _perf_add_idx; +DELIMITER $$ +CREATE PROCEDURE _perf_add_idx(IN p_table VARCHAR(64), IN p_name VARCHAR(64), IN p_cols VARCHAR(255)) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND INDEX_NAME = p_name + ) THEN + SET @_perf_sql = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_name, '` (', p_cols, ')'); + PREPARE _perf_stmt FROM @_perf_sql; + EXECUTE _perf_stmt; + DEALLOCATE PREPARE _perf_stmt; + END IF; +END$$ +DELIMITER ; + +CALL _perf_add_idx('categories','idx_categories_slug','`slug`'); +CALL _perf_add_idx('products','idx_products_category_id','`category_id`'); +CALL _perf_add_idx('products','idx_products_slug','`slug`'); +CALL _perf_add_idx('products','idx_products_status','`status`'); +CALL _perf_add_idx('news','idx_news_slug','`slug`'); +CALL _perf_add_idx('news','idx_news_status','`status`'); +CALL _perf_add_idx('cases','idx_cases_slug','`slug`'); +CALL _perf_add_idx('cases','idx_cases_status','`status`'); +CALL _perf_add_idx('pages','idx_pages_slug','`slug`'); +CALL _perf_add_idx('banners','idx_banners_status','`status`'); +CALL _perf_add_idx('banners','idx_banners_sort','`sort_order`'); +CALL _perf_add_idx('admin_users','idx_admin_users_role','`role`'); +CALL _perf_add_idx('orders','idx_orders_order_no','`order_no`'); +CALL _perf_add_idx('orders','idx_orders_status','`status`'); +CALL _perf_add_idx('orders','idx_orders_product_id','`product_id`'); +CALL _perf_add_idx('payments','idx_payments_order_id','`order_id`'); +CALL _perf_add_idx('payments','idx_payments_order_no','`order_no`'); +CALL _perf_add_idx('crm_customers','idx_customers_status','`status`'); +CALL _perf_add_idx('crm_contacts','idx_contacts_customer_id','`customer_id`'); +CALL _perf_add_idx('crm_leads','idx_leads_customer_id','`customer_id`'); +CALL _perf_add_idx('crm_followups','idx_followups_customer_id','`customer_id`'); +CALL _perf_add_idx('crm_followups','idx_followups_lead_id','`lead_id`'); +CALL _perf_add_idx('psi_materials','idx_materials_supplier_id','`supplier_id`'); +CALL _perf_add_idx('psi_materials','idx_materials_code','`code`'); +CALL _perf_add_idx('psi_products','idx_psi_products_code','`code`'); +CALL _perf_add_idx('psi_purchases','idx_purchases_supplier_id','`supplier_id`'); +CALL _perf_add_idx('psi_purchases','idx_purchases_order_no','`order_no`'); +CALL _perf_add_idx('psi_sales','idx_sales_order_no','`order_no`'); +CALL _perf_add_idx('psi_stock_moves','idx_stock_moves_item','`item_type`,`item_id`'); +CALL _perf_add_idx('settings','idx_settings_sgroup','`sgroup`'); +CALL _perf_add_idx('psi_events','idx_events_sys','`sys`'); +CALL _perf_add_idx('psi_events','idx_events_level','`level`'); + +DROP PROCEDURE IF EXISTS _perf_add_idx;