2.1 版本文件

This commit is contained in:
2026-08-14 10:55:06 +08:00
commit a3434ec620
67 changed files with 12559 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
<?php
/**
* 文件缓存系统
*
* 将数据序列化后写入 storage/cache/ 目录。
* 支持设置 TTL(过期时间)、标签清除、全局清除。
*
* 用法:
* Cache::put('user_123', $data, 3600); // 缓存 1 小时
* $data = Cache::get('user_123'); // 读取(过期返回 null)
* Cache::forget('user_123'); // 删除单个
* Cache::clear(); // 清除全部
*/
if (!defined('IN_APP')) exit('Forbidden');
class Cache
{
/** @var string 缓存目录 */
private static $dir;
/** @var bool 是否启用 */
private static $enabled = true;
/** @var string 缓存文件扩展名 */
const EXT = '.cache';
/**
* 初始化缓存系统。
* @param string $dir 缓存目录路径
* @param bool $enabled 是否启用
*/
public static function init($dir, $enabled = true)
{
self::$dir = rtrim($dir, '/\\');
self::$enabled = $enabled;
if (!is_dir(self::$dir)) {
@mkdir(self::$dir, 0755, true);
}
}
/**
* 写入缓存。
* @param string $key 缓存键
* @param mixed $value 值(任意可序列化类型)
* @param int $ttl 有效期(秒),0=永不过期
* @return bool
*/
public static function put($key, $value, $ttl = 3600)
{
if (!self::$enabled) return false;
$file = self::fileName($key);
$data = [
'expires' => $ttl > 0 ? time() + $ttl : 0,
'created' => time(),
'value' => $value,
];
$content = serialize($data);
return (bool)@file_put_contents($file, $content, LOCK_EX);
}
/**
* 读取缓存。过期或不存在返回默认值。
* @param string $key
* @param mixed $default 不存在时的默认值
* @return mixed
*/
public static function get($key, $default = null)
{
if (!self::$enabled) return $default;
$file = self::fileName($key);
if (!file_exists($file)) return $default;
$content = @file_get_contents($file);
if ($content === false) return $default;
$data = @unserialize($content);
if (!is_array($data)) {
@unlink($file);
return $default;
}
// 检查过期
if ($data['expires'] > 0 && time() > $data['expires']) {
@unlink($file);
return $default;
}
return $data['value'];
}
/**
* 检查缓存是否存在且未过期。
*/
public static function has($key)
{
return self::get($key, '__NOT_FOUND__') !== '__NOT_FOUND__';
}
/**
* 删除单个缓存。
*/
public static function forget($key)
{
$file = self::fileName($key);
if (file_exists($file)) {
return @unlink($file);
}
return true;
}
/**
* 清除所有缓存。
*/
public static function clear()
{
if (!is_dir(self::$dir)) return;
$files = glob(self::$dir . DIRECTORY_SEPARATOR . '*' . self::EXT);
foreach ($files as $f) {
@unlink($f);
}
}
/**
* 带回掉的缓存读取(缓存命中直接返回,未命中执行回调并缓存)。
* @param string $key
* @param int $ttl
* @param callable $callback 无参回调,返回要缓存的值
* @return mixed
*/
public static function remember($key, $ttl, callable $callback)
{
$value = self::get($key, null);
if ($value !== null) return $value;
$value = $callback();
self::put($key, $value, $ttl);
return $value;
}
/**
* 获取或设置(简化版 remember)。
*/
public static function getOrSet($key, $ttl, callable $callback)
{
return self::remember($key, $ttl, $callback);
}
// ─── 内部 ───
private static function fileName($key)
{
// 用 md5 确保文件名安全
return self::$dir . DIRECTORY_SEPARATOR . md5($key) . self::EXT;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
/**
* 数据库驱动抽象层
*
* 支持 MySQL (PDO/MySQL) 和 SQLite3 (PDO/SQLite)。
* 在 config.php 中通过 DB_DRIVER 配置:'mysql' 或 'sqlite'
*
* 安装时用户可选择驱动:
* - MySQL: 需要配置 DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASS
* - SQLite: 需要配置 DB_SQLITE_PATH(数据库文件路径)
*/
if (!defined('IN_APP')) exit('Forbidden');
class DbDriver
{
/** @var PDO|null 当前连接 */
private static $pdo = null;
/** @var string 驱动类型 'mysql' | 'sqlite' */
private static $driver = 'mysql';
/**
* 获取 PDO 实例(单例)。
* @return PDO
*/
public static function connect()
{
if (self::$pdo !== null) return self::$pdo;
self::$driver = defined('DB_DRIVER') ? DB_DRIVER : 'mysql';
try {
if (self::$driver === 'sqlite') {
$path = defined('DB_SQLITE_PATH') ? DB_SQLITE_PATH : (__DIR__ . '/../data/database.sqlite');
$dir = dirname($path);
if (!is_dir($dir)) mkdir($dir, 0755, true);
self::$pdo = new PDO('sqlite:' . $path);
self::$pdo->exec('PRAGMA journal_mode=WAL');
self::$pdo->exec('PRAGMA foreign_keys=ON');
} else {
// MySQL(默认)
$host = defined('DB_HOST') ? DB_HOST : 'localhost';
$port = defined('DB_PORT') ? DB_PORT : '3306';
$name = defined('DB_NAME') ? DB_NAME : 'mall';
$user = defined('DB_USER') ? DB_USER : 'root';
$pass = defined('DB_PASS') ? DB_PASS : '';
$charset = 'utf8mb4';
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset={$charset}";
self::$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
} catch (PDOException $e) {
if (defined('DEBUG') && DEBUG) {
throw $e;
}
die('数据库连接失败,请检查配置。' . (DEBUG ? ' [' . $e->getMessage() . ']' : ''));
}
return self::$pdo;
}
/**
* 获取当前驱动类型。
*/
public static function getDriver()
{
return self::$driver;
}
/**
* 是否为 SQLite。
*/
public static function isSqlite()
{
return self::$driver === 'sqlite';
}
/**
* 是否为 MySQL。
*/
public static function isMysql()
{
return self::$driver === 'mysql';
}
/**
* 获取原始 PDO 对象。
*/
public static function pdo()
{
return self::connect();
}
/**
* 关闭连接。
*/
public static function close()
{
self::$pdo = null;
}
/**
* 执行 SQL 并返回影响行数。
*/
public static function exec($sql)
{
return self::connect()->exec($sql);
}
/**
* 查询并返回全部行。
*/
public static function queryAll($sql, $params = [])
{
$stmt = self::connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* 查询并返回一行。
*/
public static function queryOne($sql, $params = [])
{
$stmt = self::connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetch();
}
/**
* 查询并返回单个值。
*/
public static function queryScalar($sql, $params = [])
{
$row = self::queryOne($sql, $params);
return $row ? reset($row) : null;
}
/**
* 获取最后插入 ID。
*/
public static function lastInsertId()
{
return self::connect()->lastInsertId();
}
/**
* 在事务中执行回调。
*/
public static function transaction(callable $callback)
{
$pdo = self::connect();
try {
$pdo->beginTransaction();
$result = $callback($pdo);
$pdo->commit();
return $result;
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
/**
* 检查表是否存在。
*/
public static function tableExists($table)
{
if (self::isSqlite()) {
$name = str_replace(self::getPrefix(), '', $table);
return self::queryScalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
[$name]
) > 0;
} else {
$db = defined('DB_NAME') ? DB_NAME : '';
return self::queryScalar(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name=?",
[$db, $table]
) > 0;
}
}
/**
* 获取表前缀。
*/
public static function getPrefix()
{
return defined('DB_PREFIX') ? DB_PREFIX : '';
}
/**
* 列出所有表名。
*/
public static function listTables()
{
if (self::isSqlite()) {
return self::queryAll("SELECT name FROM sqlite_master WHERE type='table'");
} else {
$db = defined('DB_NAME') ? DB_NAME : '';
return self::queryAll("SELECT table_name AS name FROM information_schema.tables WHERE table_schema=?", [$db]);
}
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
/**
* 多语言(i18n)系统
*
* 使用 JSON 语言包,支持动态切换。
* 用法:
* echo __('common.home'); // "首页"
* echo __('product.buy_now'); // "立即购买"
* echo __('sign.points_gained', 5); // "获得 5 积分"(支持 {points} 占位符)
* Lang::setLocale('en_US'); // 切换语言
*/
if (!defined('IN_APP')) exit('Forbidden');
class Lang
{
/** @var string 当前语言代码 */
private static $locale = 'zh_CN';
/** @var array 已加载的语言包缓存 [code => data] */
private static $cache = [];
/** @var string 语言文件目录 */
private static $dir;
/**
* 设置语言目录(通常在 init.php 中调用一次)。
*/
public static function setDir($dir)
{
self::$dir = rtrim($dir, '/\\');
}
/**
* 获取当前语言代码。
*/
public static function getLocale()
{
return self::$locale;
}
/**
* 切换语言。
* @param string $locale 如 'zh_CN', 'en_US'
* @return bool 是否成功
*/
public static function setLocale($locale)
{
$file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json';
if (!file_exists($file)) return false;
self::$locale = $locale;
// 清除旧缓存
if (isset(self::$cache[$locale])) unset(self::$cache[$locale]);
return true;
}
/**
* 从 cookie / session / 参数自动检测并设置语言。
*/
public static function detectLocale()
{
// 1. Session(由 lang.php 设置,优先级最高,保证整站会话内一致)
if (!empty($_SESSION['lang'])) {
$lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_SESSION['lang']);
if (self::setLocale($lang)) return;
}
// 2. URL 参数 ?lang=xx(一次性切换,写入 cookie + session
if (!empty($_GET['lang'])) {
$lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['lang']);
if (self::setLocale($lang)) {
$_SESSION['lang'] = $lang;
setcookie('fnw_lang', $lang, time() + 31536000, '/');
return;
}
}
// 3. Cookie
if (!empty($_COOKIE['fnw_lang'])) {
$lang = $_COOKIE['fnw_lang'];
if (self::setLocale($lang)) return;
}
// 4. 默认中文
self::$locale = 'zh_CN';
}
/**
* 翻译函数(核心)。
*
* @param string $key 点分隔键,如 'common.home' 或 'hero.title'
* @param mixed ...$args 占位参数(按顺序替换 {0}, {1}... 或命名参数)
* @return string 翻译后的文本,找不到则返回 key 本身
*/
public static function trans($key, ...$args)
{
$data = self::load(self::$locale);
$value = self::getNested($data, $key);
if ($value === null) {
// 回退到中文
if (self::$locale !== 'zh_CN') {
$dataZh = self::load('zh_CN');
$value = self::getNested($dataZh, $key);
}
if ($value === null) return $key;
}
// 替换占位符
if (!empty($args)) {
$value = self::replacePlaceholders($value, $args);
}
return $value;
}
/**
* 获取所有可用语言列表。
* @return array [['code'=>'zh_CN','name'=>'简体中文'], ...]
*/
public static function available()
{
$list = [];
if (!is_dir(self::$dir)) return $list;
foreach (glob(self::$dir . DIRECTORY_SEPARATOR . '*.json') as $f) {
$code = pathinfo($f, PATHINFO_FILENAME);
$data = json_decode(file_get_contents($f), true);
if (isset($data['_meta']['name'])) {
$list[] = [
'code' => $code,
'name' => $data['_meta']['name'],
'direction' => $data['_meta']['direction'] ?? 'ltr',
];
}
}
return $list;
}
/**
* 获取当前语言的 HTML lang 属性值。
*/
public static function htmlLang()
{
$map = ['zh_CN' => 'zh-CN', 'en_US' => 'en'];
return $map[self::$locale] ?? substr(self::$locale, 0, 2);
}
// ─── 内部方法 ───
/**
* 加载语言包(带缓存)。
*/
private static function load($locale)
{
if (isset(self::$cache[$locale])) return self::$cache[$locale];
$file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json';
if (!file_exists($file)) return [];
$json = file_get_contents($file);
self::$cache[$locale] = json_decode($json, true) ?: [];
return self::$cache[$locale];
}
/**
* 从嵌套数组中用点分隔 key 取值。
*/
private static function getNested($arr, $key)
{
$keys = explode('.', $key);
$val = $arr;
foreach ($keys as $k) {
if (!is_array($val) || !array_key_exists($k, $val)) return null;
$val = $val[$k];
}
return is_string($val) || is_numeric($val) ? (string)$val : null;
}
/**
* 替换文本中的占位符。
* 支持 {named} 和 {0}, {1} 格式。
*/
private static function replacePlaceholders($text, $args)
{
// 命名参数: __('key', ['points' => 5]) 或 __('key', 5)
if (count($args) === 1 && is_array($args[0])) {
$map = $args[0];
foreach ($map as $k => $v) {
$text = str_replace('{' . $k . '}', $v, $text);
}
} else {
// 位置参数: {0}, {1}
foreach ($args as $i => $v) {
$text = str_replace('{' . $i . '}', $v, $text);
}
}
return $text;
}
}
/**
* 全局翻译函数快捷方式。
*
* @param string $key 翻译键
* @param mixed ...$args 占位参数
* @return string
*/
if (!function_exists('__')) {
function __($key, ...$args)
{
return Lang::trans($key, ...$args);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* 系统日志类
*
* 将日志写入 storage/logs/ 目录,按日期自动分文件。
* 支持级别:DEBUG / INFO / WARN / ERROR
*
* 用法:
* Logger::info('用户登录', ['user_id' => 123]);
* Logger::error('支付失败', ['order_id' => 456, 'err' => $e->getMessage()]);
*/
if (!defined('IN_APP')) exit('Forbidden');
class Logger
{
/** @var string 日志根目录 */
private static $dir;
/** @var bool 是否启用(受 DEBUG 配置控制) */
private static $enabled = true;
/** @var int 最小记录级别 (DEBUG=0, INFO=1, WARN=2, ERROR=3) */
private static $minLevel = 0; // DEBUG 模式下记录全部
const DEBUG = 0;
const INFO = 1;
const WARN = 2;
const ERROR = 3;
/**
* 初始化日志系统。
* @param string $dir 日志目录路径
* @param bool $enabled 是否启用
* @param int $minLevel 最小级别
*/
public static function init($dir, $enabled = true, $minLevel = 0)
{
self::$dir = rtrim($dir, '/\\');
self::$enabled = $enabled;
self::$minLevel = $minLevel;
if (!is_dir(self::$dir)) {
@mkdir(self::$dir, 0755, true);
}
}
/**
* 记录 DEBUG 级别日志。
*/
public static function debug($message, array $context = [])
{
self::write(self::DEBUG, $message, $context);
}
/**
* 记录 INFO 级别日志。
*/
public static function info($message, array $context = [])
{
self::write(self::INFO, $message, $context);
}
/**
* 记录 WARN 级别日志。
*/
public static function warn($message, array $context = [])
{
self::write(self::WARN, $message, $context);
}
/**
* 记录 ERROR 级别日志。
*/
public static function error($message, array $context = [])
{
self::write(self::ERROR, $message, $context);
}
// ─── 内部实现 ───
private static function write($level, $message, array $context)
{
if (!self::$enabled || $level < self::$minLevel) return;
$levelNames = [self::DEBUG => 'DEBUG', self::INFO => 'INFO', self::WARN => 'WARN', self::ERROR => 'ERROR'];
$date = date('Y-m-d');
$time = date('Y-m-d H:i:s');
$levelName = $levelNames[$level] ?? 'LOG';
// 格式化上下文
$ctxStr = '';
if (!empty($context)) {
$parts = [];
foreach ($context as $k => $v) {
if (is_array($v) || is_object($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$parts[] = $k . '=' . $v;
}
$ctxStr = ' [' . implode(', ', $parts) . ']';
}
// 获取调用者信息(跳过 Logger 内部调用)
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
$caller = '';
if (isset($trace[2])) {
$file = basename($trace[2]['file'] ?? '');
$line = $trace[2]['line'] ?? '';
$caller = " [{$file}:{$line}]";
}
$logLine = "[{$time}] [{$levelName}]{$caller} {$message}{$ctxStr}" . PHP_EOL;
$file = self::$dir . DIRECTORY_SEPARATOR . $date . '.log';
@file_put_contents($file, $logLine, FILE_APPEND | LOCK_EX);
// 单文件最大 10MB,超出轮转
if (file_exists($file) && filesize($file) > 10 * 1024 * 1024) {
$rotated = self::$dir . DIRECTORY_SEPARATOR . $date . '.log.1';
@rename($file, $rotated);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* 支付网关抽象层
*
* 统一支付宝、微信支付的接口,供结算页面调用。
* 每种支付方式实现 PaymentGatewayInterface 接口。
*
* 支付流程:
* 1. 用户选择支付方式 → 创建支付订单(Payment::createOrder
* 2. 跳转到支付页面/显示二维码(Payment::getPayUrl / getQrCode
* 3. 支付平台异步通知 → Payment::handleNotify
* 4. 用户完成支付 → 跳转回 return_url
*/
if (!defined('IN_APP')) exit('Forbidden');
// 显式加载网关实现(类名与文件名不一致,自动加载器无法定位)
require_once __DIR__ . '/PaymentAlipay.php';
require_once __DIR__ . '/PaymentWechat.php';
interface PaymentGatewayInterface
{
/**
* 创建支付订单,返回支付 URL 或表单 HTML。
* @param array $order ['order_no', 'amount', 'subject', 'notify_url', 'return_url']
* @return array ['url' => '...', 'form' => '...'] 至少包含一个
*/
public function createOrder(array $order);
/**
* 验证异步通知签名和数据。
* @param array $params POST/GET 参数
* @return bool 是否有效
*/
public function verifyNotify(array $params);
/**
* 从通知中获取交易号。
*/
public function getTradeNo(array $params);
/**
* 从通知中获取支付状态(是否成功)。
*/
public function isPaid(array $params);
/**
* 响应支付平台(输出 "success" 等)。
*/
public function respondSuccess();
/**
* 获取网关名称。
*/
public function getName();
}
/**
* 支付管理器:注册网关、创建订单、处理回调。
*/
class Payment
{
/** @var array 已注册的支付网关 [code => instance] */
private static $gateways = [];
/**
* 注册支付网关。
* @param string $code 如 'alipay', 'wechat'
* @param PaymentGatewayInterface $gateway
*/
public static function register($code, PaymentGatewayInterface $gateway)
{
self::$gateways[$code] = $gateway;
}
/**
* 获取已注册的支付网关。
* @param string $code
* @return PaymentGatewayInterface|null
*/
public static function get($code)
{
return self::$gateways[$code] ?? null;
}
/**
* 获取所有可用支付方式列表。
* @return array [['code'=>'alipay','name'=>'支付宝','enabled'=>bool], ...]
*/
public static function availableMethods()
{
$list = [];
foreach (self::$gateways as $code => $gw) {
$list[] = [
'code' => $code,
'name' => $gw->getName(),
'enabled' => true, // TODO: 可根据配置判断是否启用
];
}
return $list;
}
/**
* 根据配置自动初始化并注册所有启用的支付网关。
* 在 init.php 中调用一次即可。
*/
public static function initGateways()
{
$alipayEnabled = getSetting('pay_alipay_enabled', '0') === '1';
$wechatEnabled = getSetting('pay_wechat_enabled', '0') === '1';
if ($alipayEnabled) {
self::register('alipay', new AlipayGateway());
}
if ($wechatEnabled) {
self::register('wechat', new WechatGateway());
}
}
/**
* 创建支付记录到数据库。
* @return int pay_log ID
*/
public static function createPayLog($orderId, $method, $amount, $tradeNo = '')
{
db()->prepare(
'INSERT INTO ' . tn('pay_logs') . ' (order_id,pay_method,amount,trade_no,status,created_at) VALUES (?,?,?,?,?,NOW())'
)->execute([$orderId, $method, $amount, $tradeNo, 'pending']);
return (int)db()->lastInsertId();
}
/**
* 更新支付日志状态。
*/
public static function updatePayStatus($logId, $status, $tradeNo = '')
{
$sql = 'UPDATE ' . tn('pay_logs') . ' SET status=?, updated_at=NOW()';
$params = [$status];
if ($tradeNo) {
$sql .= ', trade_no=?';
$params[] = $tradeNo;
}
$sql .= ' WHERE id=?';
$params[] = $logId;
db()->prepare($sql)->execute($params);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* 支付宝支付网关(电脑网站支付 / 当面付)
*
* 配置项(settings 表):
* pay_alipay_enabled - 是否启用 (1/0)
* pay_alipay_appid - 应用 APPID
* pay_alipay_private_key - 应用私钥
* pay_alipay_public_key - 支付宝公钥
*
* 使用 RSA2 签名方式。
*/
if (!defined('IN_APP')) exit('Forbidden');
class AlipayGateway implements PaymentGatewayInterface
{
private $appId;
private $privateKey;
private $publicKey;
private $gatewayUrl = 'https://openapi.alipay.com/gateway.do';
private $sandboxUrl = 'https://openapi-sandbox.dl.alipaydev.com/gateway.do';
public function __construct()
{
$this->appId = getSetting('pay_alipay_appid', '');
$this->privateKey = getSetting('pay_alipay_private_key', '');
$this->publicKey = getSetting('pay_alipay_public_key', '');
// 沙箱模式(开发测试用)
if (getSetting('pay_alipay_sandbox', '0') === '1') {
$this->gatewayUrl = $this->sandboxUrl;
}
}
public function getName()
{
return '支付宝';
}
/**
* 创建支付宝电脑网站支付订单。
*/
public function createOrder(array $order)
{
$params = [
'app_id' => $this->appId,
'method' => 'alipay.trade.page.pay',
'format' => 'JSON',
'return_url' => $order['return_url'],
'notify_url' => $order['notify_url'],
'charset' => 'utf-8',
'sign_type' => 'RSA2',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0',
'biz_content' => json_encode([
'out_trade_no' => $order['order_no'],
'total_amount' => sprintf('%.2f', $order['amount']),
'subject' => $order['subject'],
'product_code' => 'FAST_INSTANT_TRADE_PAY',
], JSON_UNESCAPED_UNICODE),
];
$params['sign'] = $this->sign($params);
// 构建跳转 URLGET 方式)
$url = $this->gatewayUrl . '?' . http_build_query($params);
return ['url' => $url, 'form' => ''];
}
public function verifyNotify(array $params)
{
if (empty($params['sign'])) return false;
$sign = $params['sign'];
unset($params['sign'], $params['sign_type']);
return $this->rsaVerify($this->buildSignData($params), $sign);
}
public function getTradeNo(array $params)
{
return $params['trade_no'] ?? '';
}
public function isPaid(array $params)
{
return ($params['trade_status'] ?? '') === 'TRADE_SUCCESS';
}
public function respondSuccess()
{
echo 'success';
}
// ─── 签名相关 ───
private function sign($params)
{
ksort($params);
$data = $this->buildSignData($params);
$res = openssl_sign($data, $signature, $this->loadPrivateKey(), OPENSSL_ALGO_SHA256);
return base64_encode($signature);
}
private function rsaVerify($data, $sign)
{
$result = openssl_verify($data, base64_decode($sign), $this->loadPublicKey(), OPENSSL_ALGO_SHA256);
return $result === 1;
}
private function buildSignData($params)
{
$pairs = [];
foreach ($params as $k => $v) {
if ($v === '' || is_array($v)) continue;
$pairs[] = $k . '=' . $v;
}
return implode('&', $pairs);
}
private function loadPrivateKey()
{
$key = trim($this->privateKey);
$key = preg_replace('/-----BEGIN RSA PRIVATE KEY-----/', '', $key);
$key = preg_replace('/-----END RSA PRIVATE KEY-----/', '', $key);
$key = str_replace(["\r", "\n", " "], '', $key);
return "-----BEGIN RSA PRIVATE KEY-----\n" .
chunk_split($key, 64, "\n") .
"-----END RSA PRIVATE KEY-----";
}
private function loadPublicKey()
{
$key = trim($this->publicKey);
$key = preg_replace('/-----BEGIN PUBLIC KEY-----/', '', $key);
$key = preg_replace('/-----END PUBLIC KEY-----/', '', $key);
$key = str_replace(["\r", "\n", " "], '', $key);
return "-----BEGIN PUBLIC KEY-----\n" .
chunk_split($key, 64, "\n") .
"-----END PUBLIC KEY-----";
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* 微信支付网关(Native 扫码支付 / H5 支付)
*
* 配置项(settings 表):
* pay_wechat_enabled - 是否启用 (1/0)
* pay_wechat_mch_id - 商户号
* pay_wechat_api_key - API 密钥
* pay_wechat_appid - 公众号/小程序 APPID(可选)
* pay_wechat_cert_path - 证书路径(退款等需要)
*
* 使用 V3 接口规范,支持 Native 扫码支付。
*/
if (!defined('IN_APP')) exit('Forbidden');
class WechatGateway implements PaymentGatewayInterface
{
private $mchId;
private $apiKey;
private $appId;
private $apiUrl = 'https://api.mch.weixin.qq.com/v3';
public function __construct()
{
$this->mchId = getSetting('pay_wechat_mch_id', '');
$this->apiKey = getSetting('pay_wechat_api_key', '');
$this->appId = getSetting('pay_wechat_appid', '');
}
public function getName()
{
return '微信支付';
}
/**
* 创建微信 Native 支付订单(返回二维码链接)。
*/
public function createOrder(array $order)
{
// 构建 V3 Native 下单请求
$url = $this->apiUrl . '/pay/transactions/native';
$body = [
'mchid' => $this->mchId,
'out_trade_no' => $order['order_no'],
'appid' => $this->appId ?: '',
'description' => $order['subject'],
'notify_url' => $order['notify_url'],
'amount' => [
'total' => (int)round($order['amount'] * 100), // 单位:分
'currency' => 'CNY',
],
];
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $this->buildAuth($url, 'POST', json_encode($body)),
];
$result = $this->httpPost($url, json_encode($body), $headers);
$data = json_decode($result, true);
if (isset($data['code_url'])) {
return [
'url' => '',
'form' => '<div class="wechat-qr-wrap"><p>请使用微信扫码支付</p>' .
'<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x&data=' . urlencode($data['code_url']) . '" alt="微信支付二维码" width="200"></div>',
'qr_code' => $data['code_url'],
];
}
// 错误时返回错误信息
Logger::error('微信支付下单失败', ['response' => $data]);
return ['url' => '', 'form' => '<p style="color:red">支付创建失败: ' . h($data['message'] ?? '未知错误') . '</p>'];
}
public function verifyNotify(array $params)
{
// V3 回调验签
if (empty($_SERVER['HTTP_WECHATPAY_SIGNATURE'])) return false;
$serial = $_SERVER['HTTP_WECHATPAY_SERIAL'] ?? '';
$signature = $_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_WECHATPAY_NONCE'] ?? '';
// TODO: 完整的 V3 验签实现需加载微信平台证书
// 这里做基础验证
return !empty($params['resource']['ciphertext']);
}
public function getTradeNo(array $params)
{
// V3 回调解密后获取 transaction_id
$resource = $params['resource'] ?? [];
return $resource['ciphertext'] ? $this->decryptResource($resource) : '';
}
public function isPaid($params)
{
$resource = $params['resource'] ?? [];
$decrypted = $resource['ciphertext'] ? json_decode($this->decryptResource($resource), true) : [];
return ($decrypted['trade_state'] ?? '') === 'SUCCESS';
}
public function respondSuccess()
{
header('Content-Type: application/json');
echo json_encode(['code' => 'SUCCESS', 'message' => '成功']);
}
// ─── V3 签名与请求 ───
private function buildAuth($url, $method, $body)
{
$timestamp = (string)time();
$nonce = bin2hex(random_bytes(16));
$signStr = "$method\n$url\n$timestamp\n$nonce\n$body\n";
// 使用 API Key 做 HMAC-SHA256 签名(简化版)
$signature = hash_hmac('SHA256', $signStr, $this->apiKey);
// 实际 V3 需要商户私钥签名,这里简化处理
return "mchid=\"{$this->mchId}\",nonce_str=\"{$nonce}\",timestamp=\"{$timestamp}\",serial_no=\"\",signature=\"$signature\"";
}
private function httpPost($url, $body, $headers = [])
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
]);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* 解密 V3 回调资源。
*/
private function decryptResource($resource)
{
$ciphertext = base64_decode($resource['ciphertext']);
$nonce = $resource['nonce'];
$associatedData = $resource['associated_data'];
// AES-256-GCM 解密
$key = hash('sha256', $this->apiKey, true); // 用 API Key 派生密钥(简化)
if (function_exists('openssl_decrypt') && defined('AES_256_GCM')) {
$plain = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $associatedData);
return $plain !== false ? $plain : '';
}
return '';
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
// 未安装时计算 install.php 的正确 web 路径并 302 跳转。
// 在加载 config.php 之前被前台 init.php 与后台 admin/login.php 引入。
// 统一处理「根目录部署」与「子目录(mall/)部署」两种场景,杜绝 //install.php 协议相对 URL。
if (!function_exists('fnw_install_redirect')) {
function fnw_install_redirect()
{
$mallRoot = realpath(__DIR__ . '/..');
$docRoot = realpath($_SERVER['DOCUMENT_ROOT'] ?? __DIR__ . '/..');
$webPath = '';
if ($mallRoot && $docRoot) {
// 统一为正斜杠,避免 Windows 下 realpath 返回反斜杠与 DOCUMENT_ROOT 不匹配
$m = str_replace('\\', '/', $mallRoot);
$d = str_replace('\\', '/', $docRoot);
if (strpos($m, $d) === 0) {
$webPath = substr($m, strlen($d));
}
}
$webPath = '/' . ltrim($webPath, '/'); // 保证以单斜杠开头
$webPath = rtrim($webPath, '/'); // 去掉末尾斜杠,杜绝 //install.php
header('Location: ' . $webPath . '/install.php');
exit;
}
}
if (!file_exists(__DIR__ . '/../config.php')) {
fnw_install_redirect();
}
+43
View File
@@ -0,0 +1,43 @@
<?php
// 数据库连接(PDO)。被 install 之外的所有页面复用。
// 安全:禁止被直接通过浏览器请求。
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
require_once __DIR__ . '/../config.php';
/**
* 返回全局唯一的 PDO 实例(懒加载 + 单例)。
*/
function db()
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
DB_HOST, DB_PORT, DB_NAME
);
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
// 不在页面上泄露密码等细节
die('数据库连接失败,请检查 config.php 中的数据库配置。');
}
return $pdo;
}
/**
* 带前缀的表名。
*/
function tn($table)
{
return DB_PREFIX . $table;
}
+107
View File
@@ -0,0 +1,107 @@
<?php
// 前台页面公共页脚。
if (!defined('IN_APP')) { http_response_code(403); exit('Forbidden'); }
?>
</main>
<footer class="site-footer">
<div class="container footer-inner">
<div class="footer-brand">
<i class="fas fa-store"></i> <?= h(SITE_NAME) ?>
<p><?= __('footer.brand_slogan') ?></p>
</div>
<div class="footer-links">
<a href="index.php"><?= __('footer.nav_home') ?></a>
<a href="index.php#products"><?= __('footer.nav_all') ?></a>
<a href="cart.php"><?= __('footer.nav_cart') ?></a>
<a href="ticket.php"><?= __('footer.nav_ticket') ?></a>
<a href="announcements.php"><?= __('footer.nav_announce') ?></a>
<a href="mytickets.php"><?= __('footer.nav_mytickets') ?></a>
<a href="myorders.php"><?= __('footer.nav_myorders') ?></a>
</div>
</div>
<div class="footer-copy">
<p>&copy; <?= date('Y') ?> <?= h(SITE_NAME) ?>. <?= __('footer.copyright') ?></p>
</div>
</footer>
<script src="assets/app.js?v=<?= $cssVer ?>"></script>
<script>
// 主题切换:写入 cookie 并即时切换 data-theme
(function () {
var btn = document.getElementById('themeToggle');
if (!btn) return;
// 初始化:根据当前主题设置正确图标(解决首次加载深色模式图标不匹配问题)
(function initThemeIcon() {
var current = document.documentElement.getAttribute('data-theme');
if (current === 'dark') {
btn.querySelector('i').className = 'fas fa-sun';
}
})();
btn.addEventListener('click', function () {
var root = document.documentElement;
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
document.cookie = 'fnw_theme=' + next + ';path=/;max-age=31536000';
btn.querySelector('i').className = next === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
});
})();
// 头像卡片:点击切换、点击外部关闭
(function () {
var btn = document.getElementById('avatarBtn');
var card = document.getElementById('userCard');
if (!btn || !card) return;
card.hidden = true; // 初始化兜底隐藏
btn.addEventListener('click', function (e) {
e.stopPropagation();
card.hidden = !card.hidden;
btn.setAttribute('aria-expanded', card.hidden ? 'false' : 'true');
});
document.addEventListener('click', function (e) {
if (!card.hidden && !card.contains(e.target) && !btn.contains(e.target)) {
card.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
});
})();
// 移动端导航:汉堡菜单展开/收起
(function () {
var toggle = document.getElementById('navToggle');
var links = document.getElementById('navLinks');
if (!toggle || !links) return;
toggle.addEventListener('click', function (e) {
e.stopPropagation();
var open = links.classList.toggle('open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// 点击菜单内链接后自动收起
links.addEventListener('click', function (e) {
if (e.target.closest('a')) links.classList.remove('open');
});
document.addEventListener('click', function (e) {
if (!links.contains(e.target) && !toggle.contains(e.target)) {
links.classList.remove('open');
}
});
})();
// 发货信息:显示/隐藏敏感凭据(登录密码)
(function () {
document.querySelectorAll('.reveal-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var span = btn.previousElementSibling;
if (!span || !span.classList.contains('secret')) return;
var real = span.getAttribute('data-secret') || '';
if (btn.textContent === '显示') {
span.textContent = real;
btn.textContent = '隐藏';
} else {
span.textContent = real ? '●'.repeat(Math.max(6, real.length)) : '—';
btn.textContent = '显示';
}
});
});
})();
</script>
</body>
</html>
+649
View File
@@ -0,0 +1,649 @@
<?php
// 公共函数:转义、认证、模板助手、CSRF、主题等。
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
/**
* HTML 转义,防止 XSS。
*/
function h($str)
{
return htmlspecialchars((string) $str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* 格式化人民币价格。
*/
function money($n)
{
return '¥' . number_format((float) $n, 2);
}
/**
* 跳转。
*/
function redirect($url)
{
// 防开放重定向 / 协议相对 URL 被当作 host(如 //evil.com 或 http://x
if (is_string($url)
&& (strpos($url, '//') === 0
|| preg_match('#^[a-z][a-z0-9+.\-]*://#i', $url))) {
$url = 'index.php';
}
header('Location: ' . $url);
exit;
}
/**
* 获取访问者真实 IP(兼容 CDN / 反向代理)。
* 优先级:X-Forwarded-For(取第一个)→ X-Real-IP → Client-IP → REMOTE_ADDR。
*/
function getClientIp()
{
$candidates = [];
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$candidates = array_merge($candidates, explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
}
foreach (['HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'] as $k) {
if (!empty($_SERVER[$k])) {
$candidates[] = $_SERVER[$k];
}
}
foreach ($candidates as $ip) {
$ip = trim($ip);
// 过滤私有/保留地址,避免 CDN 内部地址误导
if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
return trim((string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'));
}
/**
* 某 IP 今日已注册账号数(用于「每日每 IP 最多 2 个」限制)。
*/
function regCountToday($ip)
{
try {
$stmt = db()->prepare('SELECT COUNT(*) FROM ' . tn('users') . ' WHERE reg_ip = ? AND DATE(created_at) = CURDATE()');
$stmt->execute([$ip]);
return (int) $stmt->fetchColumn();
} catch (Throwable $e) {
return 0;
}
}
/**
* 站点基础 URL(用于拼接验证链接)。自动适配是否部署在子目录。
*/
function siteBaseUrl()
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? '') == '443' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$dir = dirname($_SERVER['SCRIPT_NAME'] ?? '/index.php');
$dir = ($dir === '/' || $dir === '\\') ? '' : rtrim($dir, '/');
return $scheme . '://' . $host . $dir;
}
/**
* 取昵称/用户名的首字(用于头像圆圈)。
*/
function avatarText($name)
{
$name = trim((string) $name);
if ($name === '') {
return '?';
}
return mb_strtoupper(mb_substr($name, 0, 1, 'UTF-8'), 'UTF-8');
}
/**
* 发送邮箱验证邮件:生成/更新 email_token 并投递。
* 返回 ['ok'=>bool, 'error'=>string, 'log'=>array]
*/
function sendVerifyMail($userId)
{
$stmt = db()->prepare('SELECT id, username, nickname, email FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || $user['email'] === '') {
return ['ok' => false, 'error' => '该账号没有可用邮箱', 'log' => []];
}
$token = bin2hex(random_bytes(24));
db()->prepare('UPDATE ' . tn('users') . ' SET email_token = ? WHERE id = ?')
->execute([$token, $user['id']]);
$url = siteBaseUrl() . '/verify.php?token=' . $token;
$name = $user['nickname'] ?: $user['username'];
$subject = '[' . SITE_NAME . '] 请验证你的邮箱';
$body = '<div style="font-family:Segoe UI,Helvetica,Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;'
. 'border:1px solid #e3e8ef;border-radius:16px;">'
. '<h2 style="color:#1a73e8;margin:0 0 12px;">验证你的邮箱</h2>'
. '<p style="color:#1f2430;line-height:1.7;">你好 ' . h($name) . ',感谢注册 ' . h(SITE_NAME) . '</p>'
. '<p style="color:#1f2430;line-height:1.7;">请点击下方按钮完成邮箱验证:</p>'
. '<p style="margin:18px 0;"><a href="' . h($url) . '" style="background:#1a73e8;color:#fff;padding:11px 22px;'
. 'border-radius:999px;text-decoration:none;display:inline-block;font-weight:600;">验证邮箱</a></p>'
. '<p style="color:#5f6b7a;font-size:.85rem;line-height:1.6;">如按钮无法点击,请复制以下链接到浏览器打开:<br>' . h($url) . '</p>'
. '<hr style="border:none;border-top:1px solid #eef1f6;margin:18px 0;">'
. '<p style="color:#9aa7b8;font-size:.8rem;margin:0;">若非本人操作,请忽略此邮件。</p>'
. '</div>';
return fnwSendMail($user['email'], $subject, $body);
}
/**
* 当前登录用户(普通用户),未登录返回 null。
*/
function currentUser($forceRefresh = false)
{
if (empty($_SESSION['user_id'])) {
return null;
}
static $cache = null;
if ($cache !== null && !$forceRefresh) {
return $cache;
}
$cache = null; // 强制刷新时先清掉旧缓存
$pdo = db();
$stmt = $pdo->prepare('SELECT id, username, email, nickname, verified, is_admin, status, points, invite_code, invited_by, avatar FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$row = $stmt->fetch();
// 账号被封禁则视为未登录,并清除会话
if (!$row || (int) $row['status'] !== 1) {
unset($_SESSION['user_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 清除当前登录用户的请求级缓存(后台修改用户数据后调用,使下次 currentUser() 重新查库)。
*/
function refreshCurrentUser()
{
currentUser(true);
}
function isLoggedIn()
{
return currentUser() !== null;
}
/**
* 当前登录的管理员,未登录返回 null。
*/
function currentAdmin()
{
if (empty($_SESSION['admin_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$pdo = db();
$stmt = $pdo->prepare('SELECT id, username, email, status FROM ' . tn('users') . ' WHERE id = ? AND is_admin = 1');
$stmt->execute([$_SESSION['admin_id']]);
$row = $stmt->fetch();
if (!$row || (int) $row['status'] !== 1) {
unset($_SESSION['admin_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 要求登录,否则跳转到登录页并带回跳地址。
*/
function requireLogin($next = '')
{
if (!isLoggedIn()) {
$q = $next ? '?next=' . urlencode($next) : '';
redirect('login.php' . $q);
}
}
/**
* 要求管理员(is_admin=1)登录(后台用)。
* 注意:仅校验会话标志不够——客服(role=cs)同样持有 admin_id 会话,
* 因此必须额外确认 is_admin=1,否则客服可通过直接访问 URL 进入管理员专属页面。
*/
function requireAdmin()
{
if (empty($_SESSION['admin_id']) || !currentAdmin()) {
redirect('login.php');
}
}
/**
* 当前登录的「员工」账号(管理员或客服),未登录/无权限返回 null。
* 与 currentAdmin() 的区别:currentAdmin() 仅限 is_admin=1;本函数额外允许 role='cs' 的客服。
*/
function currentStaff()
{
if (empty($_SESSION['admin_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$pdo = db();
$stmt = $pdo->prepare(
'SELECT id, username, email, status, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND status = 1 AND role IN (\'admin\', \'cs\')'
);
$stmt->execute([$_SESSION['admin_id']]);
$row = $stmt->fetch();
if (!$row) {
unset($_SESSION['admin_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 要求员工(管理员或客服)登录,否则跳转到后台登录页。
*/
function requireStaff()
{
if (empty($_SESSION['admin_id']) || !currentStaff()) {
redirect('login.php');
}
}
/**
* 当前员工是否为管理员(拥有全部后台权限)。
*/
function isAdminStaff()
{
$s = currentStaff();
return $s !== null && (int) $s['is_admin'] === 1;
}
/**
* 生成/获取 CSRF 令牌(存入 session)。
*/
function csrfToken()
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
/**
* 输出 CSRF 隐藏字段。
*/
function csrfField()
{
return '<input type="hidden" name="csrf" value="' . csrfToken() . '">';
}
/**
* 校验 CSRF(仅 POST)。失败直接 403。
*/
function verifyCsrf()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($_POST['csrf']) || !hash_equals((string) $_SESSION['csrf'], (string) $_POST['csrf'])) {
http_response_code(403);
exit('CSRF 校验失败');
}
}
}
/**
* 校验 CSRF token 字符串(用于 AJAX/API 场景)。
* @param string $token 待校验的 token
* @return bool
*/
function verifyCsrfToken($token)
{
return !empty($token) && !empty($_SESSION['csrf']) && hash_equals((string)$_SESSION['csrf'], (string)$token);
}
/**
* 当前主题:浅色 / 深色。跟随 cookie,默认跟随系统(用 CSS 媒体查询兜底)。
*/
function currentTheme()
{
return (!empty($_COOKIE['fnw_theme']) && $_COOKIE['fnw_theme'] === 'dark') ? 'dark' : 'light';
}
/**
* 订单状态中文映射。
*/
function orderStatusLabel($status)
{
$map = [
'pending' => '待处理',
'paid' => '已受理',
'shipped' => '已发货',
'completed' => '已完成',
'cancelled' => '已取消',
];
return $map[$status] ?? $status;
}
/**
* 读取单个系统设置(settings 表)。
*/
function getSetting($k, $default = '')
{
try {
$stmt = db()->prepare('SELECT v FROM ' . tn('settings') . ' WHERE k = ?');
$stmt->execute([$k]);
$v = $stmt->fetchColumn();
return $v === false ? $default : $v;
} catch (Throwable $e) {
return $default;
}
}
/**
* 保存单个系统设置(不存在则插入,存在则更新)。
*/
function saveSetting($k, $v)
{
db()->prepare(
'INSERT INTO ' . tn('settings') . ' (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?'
)->execute([$k, $v, $v]);
}
/**
* 读取 SMTP 配置(带缓存)。
*/
function smtpConfig()
{
static $c = null;
if ($c !== null) {
return $c;
}
$keys = ['smtp_host', 'smtp_port', 'smtp_enc', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_fromname', 'notify_emails'];
$map = [];
try {
$pdo = db();
$ph = implode(',', array_fill(0, count($keys), '?'));
$stmt = $pdo->prepare('SELECT k, v FROM ' . tn('settings') . ' WHERE k IN (' . $ph . ')');
$stmt->execute($keys);
foreach ($stmt->fetchAll() as $row) {
$map[$row['k']] = $row['v'];
}
} catch (Throwable $e) {
// 配置表尚不可用时返回空配置
}
$c = [];
foreach ($keys as $k) {
$c[$k] = $map[$k] ?? '';
}
return $c;
}
/**
* 通过 SMTP 发送邮件(依赖 includes/mailer.php)。
* 返回 ['ok'=>true,'log'=>[...]] 或 ['ok'=>false,'error'=>string,'log'=>[...]]
*/
function fnwSendMail($to, $subject, $body)
{
$cfg = smtpConfig();
if (empty($cfg['smtp_host'])) {
return ['ok' => false, 'error' => 'SMTP 未配置'];
}
require_once __DIR__ . '/mailer.php';
$mailer = new SmtpMailer($cfg['smtp_host'], $cfg['smtp_port'], $cfg['smtp_enc'], $cfg['smtp_user'], $cfg['smtp_pass']);
$from = $cfg['smtp_from'] ?: $cfg['smtp_user'];
$ok = $mailer->send($to, $subject, $body, $from, $cfg['smtp_fromname'] ?: SITE_NAME);
if ($ok) {
return ['ok' => true, 'log' => $mailer->log];
}
return ['ok' => false, 'error' => $mailer->lastError, 'log' => $mailer->log];
}
/**
* 工单状态中文映射。
*/
function ticketStatusLabel($status)
{
$map = [
'open' => '待处理',
'pending' => '处理中',
'resolved' => '已解决',
'closed' => '已关闭',
];
return $map[$status] ?? $status;
}
/**
* 工单优先级中文映射。
*/
function ticketPriorityLabel($p)
{
$map = [1 => '低', 2 => '普通', 3 => '高'];
return $map[(int) $p] ?? '普通';
}
/**
* 周期天数 → 中文标签。0 表示一次性/实物。
*/
function periodLabel($days)
{
$days = (int) $days;
if ($days <= 0) {
return '一次性';
}
if ($days % 365 === 0) {
return ($days / 365) . ' 年';
}
if ($days % 30 === 0) {
return ($days / 30) . ' 个月';
}
return $days . ' 天';
}
/**
* 工单类型中文映射。
*/
function ticketTypeLabel($t)
{
$map = [
'general' => '普通',
'renewal' => '续期申请',
];
return $map[$t] ?? '普通';
}
/**
* 到期时间 → 展示文案(null 表示无到期/永久)。
*/
function expiryText($dt)
{
if ($dt === null || $dt === '') {
return '—';
}
return date('Y-m-d H:i', strtotime($dt));
}
/**
* 是否即将/已经到期(用于前台续期入口判断,由调用方按需使用)。
*/
function isExpired($dt)
{
if ($dt === null || $dt === '') {
return false;
}
return strtotime($dt) < time();
}
/**
* 到期状态分析:返回是否含到期时间、展示文案、剩余天数、等级(ok/warn/danger/expired/none)。
*/
function expiryState($dt)
{
if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') {
return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none'];
}
$ts = strtotime($dt);
if ($ts === false) {
return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none'];
}
$now = time();
$days = ($ts - $now) / 86400;
$daysLeft = (int) ceil($days);
if ($ts < $now) {
return ['has' => true, 'text' => '已到期(' . date('Y-m-d H:i', $ts) . '', 'days' => $daysLeft, 'level' => 'expired'];
}
$level = $daysLeft <= 3 ? 'danger' : ($daysLeft <= 7 ? 'warn' : 'ok');
return ['has' => true, 'text' => date('Y-m-d H:i', $ts) . '(剩 ' . $daysLeft . ' 天)', 'days' => $daysLeft, 'level' => $level];
}
/**
* 到期时间徽标(HTML),用于前台订单列表/详情与后台。
*/
function expiryBadge($dt)
{
$s = expiryState($dt);
if (!$s['has']) {
return '<span class="exp-badge exp-none">无到期时间</span>';
}
$icon = [
'ok' => 'fa-calendar-check',
'warn' => 'fa-clock',
'danger' => 'fa-triangle-exclamation',
'expired' => 'fa-circle-xmark',
][$s['level']] ?? 'fa-calendar';
return '<span class="exp-badge exp-' . $s['level'] . '"><i class="fas ' . $icon . '"></i> ' . h($s['text']) . '</span>';
}
/**
* 支付方式中文映射。
*/
function payTypeLabel($t)
{
$map = [
'cash' => '现金(历史)',
'points' => '积分支付',
'direct' => '直接下单',
];
return $map[$t] ?? '直接下单';
}
/**
* 读取用户当前积分余额。
*/
function getUserPoints($uid)
{
try {
$stmt = db()->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([(int) $uid]);
$v = $stmt->fetchColumn();
return $v === false ? 0 : (int) $v;
} catch (Throwable $e) {
return 0;
}
}
/**
* 增减用户积分并写入流水(points_log)。
* $amount:正数=增加,负数=扣减(扣减后余额最低为 0)。
* 返回 ['ok'=>bool, 'balance'=>int, 'error'=>string]。
*/
function addPoints($uid, $type, $amount, $remark = '')
{
$uid = (int) $uid;
$amount = (int) $amount;
try {
$pdo = db();
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ? FOR UPDATE');
$stmt->execute([$uid]);
$cur = (int) $stmt->fetchColumn();
$balance = max(0, $cur + $amount);
$pdo->prepare('UPDATE ' . tn('users') . ' SET points = ? WHERE id = ?')
->execute([$balance, $uid]);
$pdo->prepare(
'INSERT INTO ' . tn('points_log') . ' (user_id, type, amount, balance, remark, created_at) VALUES (?,?,?,?,?,NOW())'
)->execute([$uid, $type, $amount, $balance, $remark]);
$pdo->commit();
return ['ok' => true, 'balance' => $balance, 'error' => ''];
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'balance' => 0, 'error' => $e->getMessage()];
}
}
/**
* 生成唯一邀请码(大写字母+数字,8 位)。
*/
function genInviteCode()
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
for ($i = 0; $i < 20; $i++) {
$code = '';
for ($j = 0; $j < 8; $j++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
$stmt = db()->prepare('SELECT 1 FROM ' . tn('users') . ' WHERE invite_code = ?');
$stmt->execute([$code]);
if (!$stmt->fetch()) {
return $code;
}
}
return strtoupper(substr(md5(uniqid('', true)), 0, 8));
}
/**
* 根据邀请码查邀请人 ID(不存在返回 0)。
*/
function getInviterIdByCode($code)
{
$code = trim((string) $code);
if ($code === '') {
return 0;
}
$stmt = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE invite_code = ?');
$stmt->execute([$code]);
$id = $stmt->fetchColumn();
return $id === false ? 0 : (int) $id;
}
/**
* 今天是否已签到(防重复)。
*/
function hasSignedToday($uid)
{
try {
$stmt = db()->prepare('SELECT 1 FROM ' . tn('sign_logs') . ' WHERE user_id = ? AND sign_date = CURDATE()');
$stmt->execute([(int) $uid]);
return (bool) $stmt->fetchColumn();
} catch (Throwable $e) {
return false;
}
}
/**
* 获取站点名称(优先从数据库读取)。
*/
function getSiteName() {
global $_site_name;
if ($_site_name !== null) {
return $_site_name;
}
try {
$db = db();
$stmt = $db->query("SELECT v FROM " . tn('settings') . " WHERE k = 'site_name'");
$val = $stmt->fetchColumn();
$_site_name = ($val !== false && $val !== null && $val !== '') ? $val : '自由云商城';
} catch (Exception $e) {
$_site_name = '自由云商城';
}
return $_site_name;
}
+114
View File
@@ -0,0 +1,114 @@
<?php
if (!defined('IN_APP')) {
http_response_code(403);
header("HTTP/2 403 Forbidden");
exit();
}
$pageTitle = isset($pageTitle) ? $pageTitle : getSiteName();
$user = currentUser();
$cartCount = 0;
if (!empty($_SESSION['cart'])) {
$cartCount = array_sum(array_map('intval', $_SESSION['cart']));
}
$theme = currentTheme();
$cssVer = file_exists(__DIR__ . '/../assets/style.css') ? filemtime(__DIR__ . '/../assets/style.css') : time();
$verifyNotice = '';
$verifyOk = false;
if (isset($_SESSION['verify_notice'])) {
$verifyNotice = $_SESSION['verify_notice'] === 'sent'
? '验证邮件已发送至你的邮箱,请点击邮件中的链接完成验证。'
: '抱歉,验证邮件发送失败。请在「用户设置」中点击重发。';
$verifyOk = ($_SESSION['verify_notice'] === 'sent');
unset($_SESSION['verify_notice']);
}
?>
<!DOCTYPE html>
<html lang="<?= Lang::htmlLang() ?>" data-theme="<?= $theme ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($pageTitle) ?> - <?= h(getSiteName()) ?></title>
<?php
$favicon = getSetting('favicon', '');
if (!empty($favicon) && file_exists(__DIR__ . '/../' . $favicon)) {
echo '<link rel="icon" href="' . h($favicon) . '?v=' . filemtime(__DIR__ . '/../' . $favicon) . '">' . "\n ";
}
?>
<meta name="csrf" content="<?= csrfToken() ?>">
<link rel="stylesheet" href="assets/vendor/fontawesome/all.min.css">
<link rel="stylesheet" href="assets/style.css?v=<?= $cssVer ?>">
</head>
<body>
<header class="site-header">
<div class="container nav">
<button class="nav-toggle" id="navToggle" aria-label="展开菜单" aria-expanded="false"><i class="fas fa-bars"></i></button>
<a href="/" class="brand"><i class="fas fa-store"></i> <?= h(getSiteName()) ?></a>
<nav class="nav-links" id="navLinks">
<a href="/"><i class="fas fa-home"></i> <?= __('common.home') ?></a>
<a href="/#products"><i class="fas fa-tags"></i> <?= __('common.all_products') ?></a>
<a href="ticket"><i class="fas fa-ticket-alt"></i> <?= __('common.submit_ticket') ?></a>
<a href="announcements"><i class="fas fa-bullhorn"></i> <?= __('common.announcements') ?></a>
<a href="cart"><i class="fas fa-shopping-cart"></i> <?= __('common.cart') ?><?php if ($cartCount): ?><span class="badge"><?= $cartCount ?></span><?php endif; ?></a>
</nav>
<div class="nav-actions">
<button class="theme-toggle" id="themeToggle" title="<?= __('theme.switch_theme') ?>"><i class="fas fa-moon"></i></button>
<a href="lang.php?lang=<?= Lang::getLocale() === 'zh_CN' ? 'en_US' : 'zh_CN' ?>" class="lang-switch" title="<?= Lang::getLocale() === 'zh_CN' ? 'English' : '中文' ?>">
<i class="fas fa-language"></i> <span class="lang-label"><?= Lang::getLocale() === 'zh_CN' ? 'EN' : '中' ?></span>
</a>
<?php if ($user): ?>
<div class="user-menu" id="userMenu">
<button class="avatar" id="avatarBtn" aria-haspopup="true" aria-expanded="false" title="<?= h($user['nickname'] ?: $user['username']) ?>">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= h($user['avatar']) ?>" alt="<?= h(avatarText($user['nickname'] ?: $user['username'])) ?>" style="width:100%;height:100%;object-fit:cover;border-radius:50%">
<?php else: ?>
<?= h(avatarText($user['nickname'] ?: $user['username'])) ?>
<?php endif; ?>
</button>
<div class="user-card" id="userCard" hidden>
<div class="uc-head">
<span class="uc-avatar">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= h($user['avatar']) ?>" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">
<?php else: ?>
<?= h(avatarText($user['nickname'] ?: $user['username'])) ?>
<?php endif; ?>
</span>
<div class="uc-id">
<div class="uc-name"><?= h($user['nickname'] ?: $user['username']) ?></div>
<div class="uc-handle">@<?= h($user['username']) ?></div>
<?php if (empty($user['verified'])): ?>
<span class="uc-badge unverified"><i class="fas fa-times"></i> <?= __('common.email_unverified') ?></span>
<?php else: ?>
<span class="uc-badge verified"><i class="fas fa-check"></i> <?= __('common.email_verified') ?></span>
<?php endif; ?>
</div>
</div>
<div class="uc-points"><i class="fas fa-coins"></i> <?= __('common.points') ?><strong><?= (int) getUserPoints($user['id']) ?></strong></div>
<a class="uc-item" href="myorders"><i class="fas fa-receipt"></i> <?= __('common.my_orders') ?></a>
<a class="uc-item" href="mytickets"><i class="fas fa-ticket-alt"></i> <?= __('common.my_tickets') ?></a>
<a class="uc-item" href="sign"><i class="fas fa-calendar-days"></i> <?= __('common.sign_in') ?></a>
<a class="uc-item" href="invite"><i class="fas fa-user-plus"></i> <?= __('common.invite_friends') ?></a>
<a class="uc-item" href="settings"><i class="fas fa-gear"></i> <?= __('common.settings') ?></a>
<a class="uc-item danger" href="logout"><i class="fas fa-right-from-bracket"></i> <?= __('common.logout') ?></a>
</div>
</div>
<?php else: ?>
<a href="login" class="btn btn-primary"><i class="fas fa-sign-in"></i></a>
<?php endif; ?>
</div>
</div>
</header>
<?php if ($verifyNotice): ?>
<div class="verify-banner <?= $verifyOk ? 'ok' : 'warn' ?>">
<i class="fas <?= $verifyOk ? 'fa-circle-check' : 'fa-triangle-exclamation' ?>"></i>
<span><?= h($verifyNotice) ?></span>
</div>
<?php elseif ($user && empty($user['verified'])): ?>
<div class="verify-banner warn">
<i class="fas fa-triangle-exclamation"></i>
<span>你的邮箱尚未验证,<a href="verify?resend=1">点击重新发送验证邮件</a>。</span>
</div>
<?php endif; ?>
<main class="container main">
+27
View File
@@ -0,0 +1,27 @@
<?php
session_start();
require_once __DIR__ . '/_install_redirect.php';
define('IN_APP', true);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/functions.php';
// 自动加载 includes/ 下的类文件(类名需与文件名一致,如 Logger -> Logger.php
spl_autoload_register(function ($class) {
$file = __DIR__ . '/' . $class . '.php';
if (is_file($file)) {
require $file;
}
});
require_once __DIR__ . '/Lang.php';
// 初始化多语言系统
Lang::setDir(__DIR__ . '/../assets/lang');
Lang::detectLocale();
// 初始化日志系统
Logger::init(__DIR__ . '/../storage/logs', true, defined('DEBUG') && DEBUG ? Logger::DEBUG : Logger::INFO);
// 初始化缓存系统
Cache::init(__DIR__ . '/../storage/cache', true);
+197
View File
@@ -0,0 +1,197 @@
<?php
// 纯 PHP socket 实现的 SMTP 发信类,无需 Composer / PHPMailer。
// 支持 SSL465)与 STARTTLS587)以及无加密,使用 AUTH LOGIN 鉴权。
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
class SmtpMailer
{
public $log = [];
public $lastError = '';
private $host;
private $port;
private $enc; // '' | 'ssl' | 'tls'
private $user;
private $pass;
public function __construct($host, $port, $enc, $user, $pass)
{
$this->host = (string) $host;
$this->port = (int) $port;
$this->enc = (string) $enc;
$this->user = (string) $user;
$this->pass = (string) $pass;
}
/**
* 发送邮件。
* @param string $to 收件人,多个用逗号分隔
* @param string $subject 主题
* @param string $bodyHtml 正文(HTML
* @param string $fromEmail 发件人邮箱
* @param string $fromName 发件人名称
* @return bool
*/
public function send($to, $subject, $bodyHtml, $fromEmail, $fromName)
{
if ($this->host === '') {
$this->lastError = 'SMTP 主机未配置';
return false;
}
$port = $this->port ?: ($this->enc === 'ssl' ? 465 : 587);
$timeout = 20;
$scheme = $this->enc === 'ssl' ? 'ssl' : 'tcp';
$opts = [];
if ($this->enc === 'ssl' || $this->enc === 'tls') {
$opts['ssl'] = [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
];
}
$ctx = $opts ? stream_context_create($opts) : null;
$errno = 0;
$errstr = '';
$sock = @stream_socket_client(
$scheme . '://' . $this->host . ':' . $port,
$errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx
);
if (!$sock) {
$this->lastError = "无法连接 SMTP 服务器({$this->host}:{$port}):$errstr ($errno)";
return false;
}
stream_set_timeout($sock, $timeout);
// 读取服务商标语 220
if ($this->talk($sock, null, [220]) === false) {
fclose($sock);
return false;
}
$hostHeader = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
// EHLO
if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) {
fclose($sock);
return false;
}
// STARTTLS 协商
if ($this->enc === 'tls') {
if ($this->talk($sock, 'STARTTLS', [220]) === false) {
fclose($sock);
return false;
}
if (!@stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
$this->lastError = 'STARTTLS 加密协商失败';
fclose($sock);
return false;
}
if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) {
fclose($sock);
return false;
}
}
// AUTH LOGIN
if ($this->talk($sock, 'AUTH LOGIN', [334]) === false) {
fclose($sock);
return false;
}
if ($this->talk($sock, base64_encode($this->user), [334]) === false) {
fclose($sock);
return false;
}
if ($this->talk($sock, base64_encode($this->pass), [235]) === false) {
fclose($sock);
return false;
}
// 信封
if ($this->talk($sock, 'MAIL FROM:<' . $fromEmail . '>', [250]) === false) {
fclose($sock);
return false;
}
$recipients = array_filter(array_map('trim', explode(',', $to)));
foreach ($recipients as $rcpt) {
$this->talk($sock, 'RCPT TO:<' . $rcpt . '>', [250, 251]);
}
// 信体
if ($this->talk($sock, 'DATA', [354]) === false) {
fclose($sock);
return false;
}
$data = $this->buildData($to, $subject, $bodyHtml, $fromEmail, $fromName);
fwrite($sock, $data);
if ($this->talk($sock, '.', [250]) === false) {
fclose($sock);
return false;
}
$this->talk($sock, 'QUIT', [221]);
fclose($sock);
return true;
}
private function buildData($to, $subject, $body, $fromEmail, $fromName)
{
$eol = "\r\n";
$headers = [];
$headers[] = 'From: ' . $this->encodeHeader($fromName) . ' <' . $fromEmail . '>';
$headers[] = 'To: ' . $to;
$headers[] = 'Subject: ' . $this->encodeHeader($subject);
$headers[] = 'Date: ' . date('r');
$headers[] = 'MIME-Version: 1.0';
$headers[] = 'Content-Type: text/html; charset=UTF-8';
$headers[] = 'Content-Transfer-Encoding: base64';
$msg = implode($eol, $headers) . $eol . $eol;
$msg .= chunk_split(base64_encode($body), 76, $eol);
return $msg;
}
private function encodeHeader($s)
{
if (preg_match('/[^\x00-\x7F]/', (string) $s)) {
return '=?UTF-8?B?' . base64_encode($s) . '?=';
}
return $s;
}
/**
* 与服务器交互:发送命令并读取响应。
* @param resource $sock
* @param string|null $cmd 为 null 时仅读取(用于读取初始标语)
* @param array $expectCodes 期望的状态码数组
* @return string|false
*/
private function talk($sock, $cmd, $expectCodes)
{
if ($cmd !== null) {
fwrite($sock, $cmd . "\r\n");
$this->log[] = 'C: ' . $cmd;
}
$resp = '';
while (true) {
$line = @fgets($sock, 515);
if ($line === false) {
break;
}
$resp .= $line;
// 末位第 4 个字符为空格表示响应结束(多行响应以 "- " 续接)
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
$this->log[] = 'S: ' . trim($resp);
$code = (int) substr($resp, 0, 3);
if (!in_array($code, (array) $expectCodes, true)) {
$this->lastError = '服务器返回错误:' . trim($resp);
return false;
}
return $resp;
}
}