229 lines
8.3 KiB
PHP
229 lines
8.3 KiB
PHP
<?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);
|
||
}
|
||
}
|