feat(api): 初始化笔记应用后端基础架构

- 添加环境配置文件 .env.example 包含数据库、JWT、CORS等配置
- 创建 .gitignore 文件忽略敏感文件和临时文件
- 配置 Apache 重写规则支持路由转发
- 实现 JWT 认证中间件提供用户身份验证功能
- 添加 MySQL 数据库初始化脚本包含分组、图片、笔记表结构
This commit is contained in:
ikmkj
2026-01-26 08:49:10 +08:00
parent 0426ad22b7
commit 90f63d9df1
31 changed files with 1898 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\Grouping;
use App\Utils\Response as ApiResponse;
class GroupingController
{
public function getAll(Request $request, Response $response)
{
$params = $request->getQueryParams();
$parentId = $params['parentId'] ?? null;
$model = new Grouping();
$groupings = $model->getAll($parentId);
$response->getBody()->write(json_encode(ApiResponse::success($groupings), JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function create(Request $request, Response $response)
{
$data = $request->getParsedBody();
$grouping = $data['grouping'] ?? '';
$parentId = $data['parentId'] ?? 0;
if (empty($grouping)) {
$response->getBody()->write(json_encode(ApiResponse::fail('分组名称不能为空'), JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$model = new Grouping();
$id = $model->create($grouping, $parentId);
$created = $model->findById($id);
$response->getBody()->write(json_encode(ApiResponse::success($created), JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\MarkdownFile;
use App\Utils\Response as ApiResponse;
class MarkdownController
{
public function getById(Request $request, Response $response, $args)
{
$id = $args['id'];
$model = new MarkdownFile();
$file = $model->findById($id);
if (!$file) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('文件不存在')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(404);
}
// 检查是否为私密笔记
if ($file['is_private'] == 1) {
// 检查是否已认证
$userId = $request->getAttribute('userId');
if (!$userId) {
// 未认证,返回空内容
$response->getBody()->write(ApiResponse::json(ApiResponse::success('')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
}
$response->getBody()->write(ApiResponse::json(ApiResponse::success($file['content'])));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function getAll(Request $request, Response $response)
{
$model = new MarkdownFile();
$files = $model->getAll();
$response->getBody()->write(ApiResponse::json(ApiResponse::success($files)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function update(Request $request, Response $response)
{
$data = $request->getParsedBody();
$id = $data['id'] ?? null;
if (!$id) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('缺少文件ID')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$model = new MarkdownFile();
$model->update($id, $data);
$file = $model->findById($id);
$response->getBody()->write(ApiResponse::json(ApiResponse::success($file)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function delete(Request $request, Response $response, $args)
{
$id = $args['id'];
$userId = $request->getAttribute('userId');
$model = new MarkdownFile();
$model->softDelete($id, $userId);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '删除成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\User;
use App\Models\SystemSetting;
use App\Models\RegistrationCode;
use App\Utils\JWTUtil;
use App\Utils\Response as ApiResponse;
class UserController
{
public function register(Request $request, Response $response)
{
$data = $request->getParsedBody();
$username = $data['username'] ?? '';
$password = $data['password'] ?? '';
$email = $data['email'] ?? '';
$registrationCode = $data['registrationCode'] ?? '';
// 验证输入
if (empty($username) || empty($password) || empty($email)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('用户名、密码和邮箱不能为空')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
// 检查注册功能是否开启
$systemSetting = new SystemSetting();
if (!$systemSetting->isRegistrationEnabled()) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('注册功能已关闭')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(403);
}
// 验证注册码
$regCodeModel = new RegistrationCode();
if (!$regCodeModel->validateCode($registrationCode)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('无效或已过期的注册码')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$userModel = new User();
// 检查用户名是否已存在
if ($userModel->findByUsername($username)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('用户名已存在')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
try {
$userId = $userModel->create($username, $password, $email);
$user = $userModel->findById($userId);
unset($user['password']); // 不返回密码
$response->getBody()->write(ApiResponse::json(ApiResponse::success($user, '注册成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('注册失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
public function login(Request $request, Response $response)
{
$data = $request->getParsedBody();
$username = $data['username'] ?? '';
$password = $data['password'] ?? '';
if (empty($username) || empty($password)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('用户名和密码不能为空')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$userModel = new User();
$user = $userModel->findByUsername($username);
if (!$user || !$userModel->verifyPassword($password, $user['password'])) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('用户名或密码错误')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(401);
}
// 生成 JWT token
$token = JWTUtil::encode($user['id'], $user['username']);
$expireTime = date('Y-m-d H:i:s', time() + ($_ENV['JWT_EXPIRE'] ?? 86400));
// 更新数据库中的 token
$userModel->updateToken($user['id'], $token, $expireTime);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(['token' => $token], '登录成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function validateToken(Request $request, Response $response)
{
// 如果能到达这里,说明 token 已经通过中间件验证
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, 'Token is valid')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function updatePassword(Request $request, Response $response)
{
$userId = $request->getAttribute('userId');
$data = $request->getParsedBody();
$oldPassword = $data['oldPassword'] ?? '';
$newPassword = $data['newPassword'] ?? '';
if (empty($oldPassword) || empty($newPassword)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('旧密码和新密码不能为空')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$userModel = new User();
$user = $userModel->findById($userId);
if (!$userModel->verifyPassword($oldPassword, $user['password'])) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('旧密码错误')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$userModel->updatePassword($userId, $newPassword);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '密码更新成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function deleteUser(Request $request, Response $response)
{
$userId = $request->getAttribute('userId');
$userModel = new User();
$userModel->delete($userId);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '用户删除成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Middleware;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response;
use App\Utils\JWTUtil;
class AuthMiddleware
{
public function __invoke(Request $request, RequestHandler $handler): Response
{
$authHeader = $request->getHeaderLine('Authorization');
if (empty($authHeader)) {
$response = new Response();
$response->getBody()->write(json_encode([
'code' => 401,
'message' => '未提供认证令牌',
'data' => null
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(401);
}
// 提取 token (Bearer token)
$token = str_replace('Bearer ', '', $authHeader);
$decoded = JWTUtil::decode($token);
if ($decoded === null) {
$response = new Response();
$response->getBody()->write(json_encode([
'code' => 401,
'message' => '无效或过期的令牌',
'data' => null
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(401);
}
// 将用户信息添加到请求属性中
$request = $request->withAttribute('userId', $decoded['userId']);
$request = $request->withAttribute('username', $decoded['username']);
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Middleware;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Response;
class CorsMiddleware
{
public function __invoke(Request $request, RequestHandler $handler): Response
{
$response = $handler->handle($request);
$origin = $_ENV['CORS_ORIGIN'] ?? '*';
return $response
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Origin, Authorization')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
->withHeader('Access-Control-Allow-Credentials', 'true');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use App\Utils\Database;
use PDO;
class Grouping
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
public function getAll($parentId = null)
{
if ($parentId === null) {
$stmt = $this->db->prepare("SELECT * FROM `grouping` WHERE is_deleted = 0 ORDER BY id");
} else {
$stmt = $this->db->prepare("SELECT * FROM `grouping` WHERE parentId = ? AND is_deleted = 0 ORDER BY id");
$stmt->execute([$parentId]);
return $stmt->fetchAll();
}
$stmt->execute();
return $stmt->fetchAll();
}
public function findById($id)
{
$stmt = $this->db->prepare("SELECT * FROM `grouping` WHERE id = ? AND is_deleted = 0");
$stmt->execute([$id]);
return $stmt->fetch();
}
public function create($grouping, $parentId = 0)
{
$stmt = $this->db->prepare("INSERT INTO `grouping` (`grouping`, parentId) VALUES (?, ?)");
$stmt->execute([$grouping, $parentId]);
return $this->db->lastInsertId();
}
public function update($id, $grouping)
{
$stmt = $this->db->prepare("UPDATE `grouping` SET `grouping` = ? WHERE id = ?");
return $stmt->execute([$grouping, $id]);
}
public function softDelete($id, $userId)
{
$stmt = $this->db->prepare(
"UPDATE `grouping` SET is_deleted = 1, deleted_at = NOW(), deleted_by = ? WHERE id = ?"
);
return $stmt->execute([$userId, $id]);
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Models;
use App\Utils\Database;
use PDO;
class MarkdownFile
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
public function findById($id, $includeDeleted = false)
{
$sql = "SELECT * FROM markdown_file WHERE id = ?";
if (!$includeDeleted) {
$sql .= " AND is_deleted = 0";
}
$stmt = $this->db->prepare($sql);
$stmt->execute([$id]);
return $stmt->fetch();
}
public function getAll()
{
$stmt = $this->db->prepare("SELECT * FROM markdown_file WHERE is_deleted = 0 ORDER BY updated_at DESC");
$stmt->execute();
return $stmt->fetchAll();
}
public function getByGroupingId($groupingId)
{
$stmt = $this->db->prepare(
"SELECT id, title, file_name, created_at, updated_at, is_private, grouping_id
FROM markdown_file
WHERE grouping_id = ? AND is_deleted = 0
ORDER BY updated_at DESC"
);
$stmt->execute([$groupingId]);
return $stmt->fetchAll();
}
public function searchByTitle($keyword)
{
$stmt = $this->db->prepare(
"SELECT * FROM markdown_file
WHERE title LIKE ? AND is_deleted = 0
ORDER BY updated_at DESC"
);
$stmt->execute(['%' . $keyword . '%']);
return $stmt->fetchAll();
}
public function getRecent($limit = 12)
{
$stmt = $this->db->prepare(
"SELECT id, title, file_name, created_at, updated_at, is_private, grouping_id
FROM markdown_file
WHERE is_deleted = 0
ORDER BY updated_at DESC
LIMIT ?"
);
$stmt->execute([$limit]);
return $stmt->fetchAll();
}
public function update($id, $data)
{
$fields = [];
$values = [];
if (isset($data['title'])) {
$fields[] = "title = ?";
$values[] = $data['title'];
}
if (isset($data['content'])) {
$fields[] = "content = ?";
$values[] = $data['content'];
}
if (isset($data['grouping_id'])) {
$fields[] = "grouping_id = ?";
$values[] = $data['grouping_id'];
}
if (isset($data['is_private'])) {
$fields[] = "is_private = ?";
$values[] = $data['is_private'];
}
$fields[] = "updated_at = NOW()";
$values[] = $id;
$sql = "UPDATE markdown_file SET " . implode(", ", $fields) . " WHERE id = ?";
$stmt = $this->db->prepare($sql);
return $stmt->execute($values);
}
public function softDelete($id, $userId)
{
$stmt = $this->db->prepare(
"UPDATE markdown_file SET is_deleted = 1, deleted_at = NOW(), deleted_by = ? WHERE id = ?"
);
return $stmt->execute([$userId, $id]);
}
public function restore($id)
{
$stmt = $this->db->prepare(
"UPDATE markdown_file SET is_deleted = 0, deleted_at = NULL, deleted_by = NULL WHERE id = ?"
);
return $stmt->execute([$id]);
}
public function permanentDelete($id)
{
$stmt = $this->db->prepare("DELETE FROM markdown_file WHERE id = ?");
return $stmt->execute([$id]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Utils\Database;
use PDO;
class RegistrationCode
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
public function generateCode($createdBy)
{
$code = bin2hex(random_bytes(16));
$expiryTime = date('Y-m-d H:i:s', strtotime('+7 days'));
$stmt = $this->db->prepare(
"INSERT INTO registration_codes (code, expiry_time, created_by, created_at) VALUES (?, ?, ?, NOW())"
);
$stmt->execute([$code, $expiryTime, $createdBy]);
return $code;
}
public function validateCode($code)
{
$stmt = $this->db->prepare(
"SELECT * FROM registration_codes WHERE code = ? AND expiry_time > NOW()"
);
$stmt->execute([$code]);
return $stmt->fetch() !== false;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Utils\Database;
use PDO;
class SystemSetting
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
public function getSetting($key)
{
$stmt = $this->db->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?");
$stmt->execute([$key]);
$result = $stmt->fetch();
return $result ? $result['setting_value'] : null;
}
public function setSetting($key, $value)
{
$stmt = $this->db->prepare(
"INSERT INTO system_settings (setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = ?"
);
return $stmt->execute([$key, $value, $value]);
}
public function isRegistrationEnabled()
{
$value = $this->getSetting('registration_enabled');
return $value === '1' || $value === 'true';
}
public function setRegistrationEnabled($enabled)
{
return $this->setSetting('registration_enabled', $enabled ? '1' : '0');
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models;
use App\Utils\Database;
use PDO;
class User
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
public function findByUsername($username)
{
$stmt = $this->db->prepare("SELECT * FROM user WHERE username = ?");
$stmt->execute([$username]);
return $stmt->fetch();
}
public function findById($id)
{
$stmt = $this->db->prepare("SELECT * FROM user WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
public function create($username, $password, $email)
{
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
$stmt = $this->db->prepare(
"INSERT INTO user (username, password, email, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"
);
$stmt->execute([$username, $hashedPassword, $email]);
return $this->db->lastInsertId();
}
public function updateToken($userId, $token, $expireTime)
{
$stmt = $this->db->prepare("UPDATE user SET token = ?, token_enddata = ? WHERE id = ?");
return $stmt->execute([$token, $expireTime, $userId]);
}
public function updatePassword($userId, $newPassword)
{
$hashedPassword = password_hash($newPassword, PASSWORD_BCRYPT);
$stmt = $this->db->prepare("UPDATE user SET password = ?, updated_at = NOW() WHERE id = ?");
return $stmt->execute([$hashedPassword, $userId]);
}
public function delete($userId)
{
$stmt = $this->db->prepare("DELETE FROM user WHERE id = ?");
return $stmt->execute([$userId]);
}
public function verifyPassword($password, $hashedPassword)
{
return password_verify($password, $hashedPassword);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Utils;
use PDO;
use PDOException;
class Database
{
private static $instance = null;
private $connection;
private function __construct()
{
$config = require __DIR__ . '/../../config/database.php';
$dsn = sprintf(
"mysql:host=%s;port=%s;dbname=%s;charset=%s",
$config['host'],
$config['port'],
$config['database'],
$config['charset']
);
try {
$this->connection = new PDO(
$dsn,
$config['username'],
$config['password'],
$config['options']
);
} catch (PDOException $e) {
throw new \Exception("数据库连接失败: " . $e->getMessage());
}
}
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection()
{
return $this->connection;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Utils;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class JWTUtil
{
private static $secret;
private static $expire;
public static function init()
{
self::$secret = $_ENV['JWT_SECRET'] ?? 'default-secret-key';
self::$expire = (int)($_ENV['JWT_EXPIRE'] ?? 86400);
}
public static function encode($userId, $username)
{
self::init();
$payload = [
'iss' => 'biji-php',
'iat' => time(),
'exp' => time() + self::$expire,
'userId' => $userId,
'username' => $username
];
return JWT::encode($payload, self::$secret, 'HS256');
}
public static function decode($token)
{
self::init();
try {
$decoded = JWT::decode($token, new Key(self::$secret, 'HS256'));
return (array)$decoded;
} catch (\Exception $e) {
return null;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Utils;
class Response
{
public static function success($data = null, $message = '操作成功')
{
return [
'code' => 200,
'message' => $message,
'data' => $data
];
}
public static function fail($message = '操作失败', $code = 400)
{
return [
'code' => $code,
'message' => $message,
'data' => null
];
}
public static function error($message = '服务器错误', $code = 500)
{
return [
'code' => $code,
'message' => $message,
'data' => null
];
}
/**
* 将数组转换为 JSON 字符串(中文不转义)
*/
public static function json($data)
{
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}