Files
2026-08-08 21:34:47 +08:00

229 lines
8.3 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Core;
/**
* 模型基类:同时支持 MySQL 与 文件(JSON) 两种存储
* 子类设置 $table 与 $orderBy 即可。
*/
class Model
{
protected $table;
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
{
return Db::fileDir() . '/' . $this->table . '.json';
}
private function read(): array
{
$f = $this->file();
if (!is_file($f)) return [];
$d = json_decode(file_get_contents($f), true);
return is_array($d) ? $d : [];
}
private function write(array $rows): void
{
$rows = $this->sanitizeUtf8($rows);
// JSON_INVALID_UTF8_SUBSTITUTE (PHP 7.2+) 保证即使存在非法 UTF-8 也不会让 json_encode 返回 false
// 避免 file_put_contents(false) 把整个数据文件清空为 0 字节(灾难性数据丢失)。
$json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE);
if ($json === false || $json === '') {
error_log('[Model::write] json_encode failed for ' . $this->table . '; aborting write to avoid data loss');
return;
}
$f = $this->file();
$dir = dirname($f);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
// 写入失败(最常见:服务器目录/文件权限不足,PHP 进程无写权限)必须记录日志,
// 否则会“假成功”——页面跳转回去、用户以为保存了,数据却没变。
$bytes = @file_put_contents($f, $json);
if ($bytes === false) {
$err = error_get_last();
error_log('[Model::write] FAILED to write ' . $f . ' — ' . ($err['message'] ?? 'unknown error') .
' | 请检查目录/文件所有者是否为 PHP 运行用户(宝塔通常是 www)并赋予写权限');
}
}
/** 递归将数组中的字符串修复为合法 UTF-8,剔除非法字节序列 */
private function sanitizeUtf8($v)
{
if (is_array($v)) {
return array_map([$this, 'sanitizeUtf8'], $v);
}
if (is_string($v) && !mb_check_encoding($v, 'UTF-8')) {
return mb_convert_encoding($v, 'UTF-8', 'UTF-8');
}
return $v;
}
private function sort(array &$rows): void
{
$col = $this->orderBy;
usort($rows, function ($a, $b) use ($col) {
$va = $a[$col] ?? 0; $vb = $b[$col] ?? 0;
return $va <=> $vb;
});
}
/* ---------- 通用 CRUD ---------- */
public function all(): array
{
$k = $this->cacheKey('all');
$hit = $this->cacheGet($k);
if ($hit !== null) return $hit;
if (Db::driver() === 'mysql') {
$rows = Db::query("SELECT * FROM `{$this->table}` ORDER BY `{$this->orderBy}` ASC")->fetchAll();
} else {
$rows = $this->read(); $this->sort($rows);
}
$this->cachePut($k, $rows);
return $rows;
}
public function find($id)
{
if (Db::driver() === 'mysql') {
return Db::query("SELECT * FROM `{$this->table}` WHERE `{$this->pk}`=?", [$id])->fetch() ?: null;
}
foreach ($this->read() as $r) if (($r[$this->pk] ?? null) == $id) return $r;
return null;
}
public function where(string $col, $val)
{
if (Db::driver() === 'mysql') {
return Db::query("SELECT * FROM `{$this->table}` WHERE `{$col}`=?", [$val])->fetch() ?: null;
}
foreach ($this->read() as $r) if (($r[$col] ?? null) == $val) return $r;
return null;
}
public function whereAll(string $col, $val): array
{
if (Db::driver() === 'mysql') {
return Db::query("SELECT * FROM `{$this->table}` WHERE `{$col}`=? ORDER BY `{$this->orderBy}` ASC", [$val])->fetchAll();
}
$out = []; foreach ($this->read() as $r) if (($r[$col] ?? null) == $val) $out[] = $r;
$this->sort($out); return $out;
}
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), '?')) . ")";
Db::query($sql, array_values($data));
return Db::pdo()->lastInsertId();
}
$rows = $this->read();
$id = $rows ? (max(array_column($rows, $this->pk)) + 1) : 1;
$data[$this->pk] = $id;
$rows[] = $data; $this->write($rows);
return $id;
}
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}`=?";
Db::query($sql, array_merge(array_values($data), [$id]));
return;
}
$rows = $this->read();
foreach ($rows as &$r) {
if (($r[$this->pk] ?? null) == $id) { $r = array_merge($r, $data); break; }
}
$this->write($rows);
}
public function delete($id): void
{
$this->cacheFlush();
if (Db::driver() === 'mysql') {
Db::query("DELETE FROM `{$this->table}` WHERE `{$this->pk}`=?", [$id]);
return;
}
$rows = array_filter($this->read(), fn($r) => ($r[$this->pk] ?? null) != $id);
$this->write(array_values($rows));
}
/** 按任意列批量删除(用于主从表级联删除从表) */
public function deleteRaw(string $col, $val): void
{
$this->cacheFlush();
if (Db::driver() === 'mysql') {
Db::query("DELETE FROM `{$this->table}` WHERE `{$col}`=?", [$val]);
return;
}
$rows = array_filter($this->read(), fn($r) => ($r[$col] ?? null) != $val);
$this->write(array_values($rows));
}
/**
* 计数:MySQL 下走 COUNT(*) 聚合(避免 SELECT * 全表拉回再 count);
* 可选按某列过滤。文件模式下回退为本地计数。
* @param string|null $col 过滤列(null 表示全表计数)
* @param mixed $val 过滤值
*/
public function count(?string $col = null, $val = null): int
{
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);
}
}