mormor2与vue搭建一个博客系统

界面是这样的

后端代码:

复制代码
program BlogServer;

{$APPTYPE CONSOLE}

{$R *.res}

uses
  Winapi.Windows,
  System.SysUtils,
  System.Classes,
  System.IOUtils,
  mormot.core.base,
  mormot.core.data,
  mormot.core.json,
  mormot.core.log,
  mormot.core.text,
  mormot.core.os,
  mormot.core.buffers,
  mormot.core.rtti,
  mormot.core.perf,
  mormot.core.threads,
  mormot.orm.core,
  mormot.orm.sqlite3,
  mormot.orm.storage,
  mormot.rest.core,
  mormot.rest.server,
  mormot.rest.http.server,
  mormot.rest.memserver,
  mormot.rest.sqlite3,  // 添加这个单元!这是关键
  mormot.db.raw.sqlite3.static,
  mormot.crypt.core,
  mormot.net.http,
  mormot.net.ws.core,
  mormot.core.interfaces,
  mormot.net.server,
  Blog.Model,
  Blog.JWT,
  Blog.Service;

const
  DEFAULT_PORT = '8080';
  DEFAULT_ADMIN_USERNAME = 'admin';
  DEFAULT_ADMIN_PASSWORD = 'admin123';
  DEFAULT_ADMIN_EMAIL = 'admin@blog.com';
  CONFIG_FILENAME = 'blogserver.json';

type
  TServerConfig = record
    Port: string;
    DatabaseFile: string;
    JWTSecret: string;
    JWTExpirationMinutes: Integer;
    EnableCORS: Boolean;
    EnableWebSocket: Boolean;
    LogLevel: TSynLogInfo;
    CreateDefaultAdmin: Boolean;
    procedure LoadFromFile(const AFilename: string);
    procedure SaveToFile(const AFilename: string);
    procedure SetDefault;
  end;

type
  TStaticFileHandler = class
  private
    fUploadPath: string;
  public
    constructor Create(const UploadPath: string);
    function HandleRequest(var Call: TRestUriParams): Boolean;
  end;

constructor TStaticFileHandler.Create(const UploadPath: string);
begin
  inherited Create;
  fUploadPath := IncludeTrailingPathDelimiter(UploadPath);
end;

// MIME类型辅助函数
function GetMimeContentType(const Ext: string): RawUtf8;
begin
  if (Ext = '.jpg') or (Ext = '.jpeg') then
    Result := 'image/jpeg'
  else if Ext = '.png' then
    Result := 'image/png'
  else if Ext = '.gif' then
    Result := 'image/gif'
  else if Ext = '.webp' then
    Result := 'image/webp'
  else if Ext = '.bmp' then
    Result := 'image/bmp'
  else if Ext = '.ico' then
    Result := 'image/x-icon'
  else if Ext = '.svg' then
    Result := 'image/svg+xml'
  else if Ext = '.txt' then
    Result := 'text/plain'
  else if Ext = '.html' then
    Result := 'text/html'
  else if Ext = '.css' then
    Result := 'text/css'
  else if Ext = '.js' then
    Result := 'application/javascript'
  else if Ext = '.json' then
    Result := 'application/json'
  else if (Ext = '.pdf') then
    Result := 'application/pdf'
  else if (Ext = '.zip') then
    Result := 'application/zip'
  else
    Result := 'application/octet-stream';
end;


function TStaticFileHandler.HandleRequest(var Call: TRestUriParams): Boolean;
var
  FilePath, LocalFile, MimeType: RawUtf8;
  ResponseBody: RawByteString;
begin
  Result := False;

  // 只处理 GET 请求
  if Call.Method <> 'GET' then
    Exit;

  // 只处理 /static/ 开头的请求
  if Pos('/static/', Call.Url) < 1 then
    Exit;

  // 提取文件路径
  FilePath := Copy(Call.Url, Pos('/static/', Call.Url) + Length('/static/') , MaxInt);
  FilePath := stringreplace(FilePath,'uploads/','',[rfReplaceAll]);

  if FilePath = '' then
  begin
    Call.OutStatus := HTTP_BADREQUEST;
    Call.OutBody := 'File path required';
    Call.OutHead := 'Content-Type: text/plain';
    Result := True;
    Exit;
  end;

  // 安全检查
  if (Pos('..', FilePath) > 0) or (Pos('\\', FilePath) > 0) or
     (Pos('//', FilePath) > 0) or (Pos(':', FilePath) > 0) then
  begin
    Call.OutStatus := HTTP_FORBIDDEN;
    Call.OutBody := 'Access forbidden';
    Call.OutHead := 'Content-Type: text/plain';
    Result := True;
    Exit;
  end;

  // 构建本地路径
  LocalFile := fUploadPath + StringReplace(FilePath, '/', '\', [rfReplaceAll]);
  LocalFile := urldecode(LocalFile);
  // 检查文件是否存在
  if not FileExists(Utf8ToString(LocalFile)) then
  begin
    Call.OutStatus := HTTP_NOTFOUND;
    Call.OutBody := 'File not found';
    Call.OutHead := 'Content-Type: text/plain';
    Result := True;
    Exit;
  end;

  try

    try

      // ⚠️ 关键:直接赋值给 Call.OutBody(作为 RawByteString)
      Call.OutBody := StringFromFile(LocalFile);  // RawUtf8 和 RawByteString 在 mORMot 中是兼容的

    finally

    end;

    // 设置 MIME 类型
    var ss := LowerCase(ExtractFileExt(LocalFile));
    MimeType := GetMimeContentType(ss);
    Call.OutHead := 'Content-Type: ' + MimeType + #13#10 ;
    Call.OutStatus := HTTP_SUCCESS;
    Result := True;

  except
    on E: Exception do
    begin
      Call.OutStatus := HTTP_SERVERERROR;
      Call.OutBody := 'Server error: ' + RawUtf8(E.Message);
      Call.OutHead := 'Content-Type: text/plain';
      Result := True;
    end;
  end;
end;

var
  Model: TOrmModel;
  RestServer: TRestServerDB;  // 保持 TRestServerDB
  HttpServer: TRestHttpServer;
  Config: TServerConfig;
  Logger: TSynLog;
  ShutdownRequested: Boolean = False;
  StaticFileHandler: TStaticFileHandler;

// 控制台事件处理函数
function ConsoleHandler(dwCtrlType: DWORD): BOOL; stdcall;
begin
  Result := True;
  case dwCtrlType of
    CTRL_C_EVENT,
    CTRL_BREAK_EVENT,
    CTRL_CLOSE_EVENT:
    begin
      ShutdownRequested := True;
      WriteLn('');
      WriteLn('Shutdown signal received. Gracefully stopping server...');
    end;
  end;
end;

procedure SetupConsoleCtrlHandler;
begin
  SetConsoleCtrlHandler(@ConsoleHandler, True);
end;

procedure TServerConfig.SetDefault;
begin
  Port := DEFAULT_PORT;
  DatabaseFile := 'blog.db3';
  JWTSecret := 'ChangeThisToAStrongSecretKeyAtLeast32Chars';
  JWTExpirationMinutes := 60;
  EnableCORS := True;
  EnableWebSocket := True;
  LogLevel := sllInfo;
  CreateDefaultAdmin := True;
end;

procedure TServerConfig.LoadFromFile(const AFilename: string);
var
  Json: RawUtf8;
begin
  SetDefault;
  if FileExists(AFilename) then
  begin
    Json := StringFromFile(AFilename);
    RecordLoadJson(Self, Json, TypeInfo(TServerConfig));
  end;
end;

procedure TServerConfig.SaveToFile(const AFilename: string);
var
  Json: RawUtf8;
begin
  Json := RecordSaveJson(Self, TypeInfo(TServerConfig));
  FileFromString(Json, AFilename);
end;


function ContainsPathTraversal(const Path: RawUtf8): Boolean;
begin
  Result := (Pos('..', Path) > 0) or
            (Pos('\\', Path) > 0) or
            (Pos(':', Path) > 0);
end;





// 检查并创建上传目录
procedure EnsureUploadsDirectory;
var
  UploadDir: string;
begin
  UploadDir := ExtractFilePath(ParamStr(0)) + 'uploads';
  if not DirectoryExists(UploadDir) then
  begin
    WriteLn('Creating uploads directory: ', UploadDir);
    ForceDirectories(UploadDir);

    // 创建子目录结构
    ForceDirectories(UploadDir + '\images');
    ForceDirectories(UploadDir + '\thumbnails');
    ForceDirectories(UploadDir + '\documents');

    // 添加说明文件
    var ReadmeFile := UploadDir + '\README.txt';
    var fs := TFileStream.Create(ReadmeFile, fmCreate);
    try
      var Content :=
        'This directory is used by the Blog Server to store uploaded files.'#13#10 +
        'Structure:'#13#10 +
        '- images/      : Uploaded images'#13#10 +
        '- thumbnails/  : Image thumbnails (if generated)'#13#10 +
        '- documents/   : Other uploaded files'#13#10#13#10 +
        'DO NOT DELETE files manually unless you know what you are doing.';
      fs.WriteBuffer(Pointer(Content)^, Length(Content));
    finally
      fs.Free;
    end;
  end;
end;



procedure InitializeDatabase;
var
  User: TUser;
  HashedPassword: RawUtf8;
begin
  // 创建默认管理员账户(如果需要)
  if Config.CreateDefaultAdmin and (RestServer.Orm.TableRowCount(TUser) = 0) then
  begin
    Logger.Log(sllInfo, 'Creating default admin account...');

    User := TUser.Create;
    try
      User.Username := DEFAULT_ADMIN_USERNAME;
      HashedPassword := HashPassword(DEFAULT_ADMIN_PASSWORD);
      User.Password := HashedPassword;
      User.Email := DEFAULT_ADMIN_EMAIL;
      User.CreatedAt := NowUTC;

      if RestServer.Orm.Add(User, True) > 0 then
      begin
        Logger.Log(sllInfo, 'Default admin account created: %s/%s',
          [DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD]);
      end
      else
      begin
        Logger.Log(sllWarning, 'Failed to create admin account');
      end;
    finally
      User.Free;
    end;
  end;
end;

procedure ShowServerInfo;
begin
  WriteLn('');
  WriteLn('╔══════════════════════════════════════════════════════╗');
  WriteLn('║               Blog Server v1.0                       ║');
  WriteLn('╠══════════════════════════════════════════════════════╣');
  WriteLn('║ HTTP Server:  http://localhost:', Config.Port, StringOfChar(' ', 27 - Length(Config.Port)), '║');
  WriteLn('║ Database:     ', Config.DatabaseFile, StringOfChar(' ', 35 - Length(Config.DatabaseFile)), '║');
  WriteLn('║ Log Level:    ', GetEnumName(TypeInfo(TSynLogInfo), Ord(Config.LogLevel))^,
         StringOfChar(' ', 35 - Length(GetEnumName(TypeInfo(TSynLogInfo), Ord(Config.LogLevel))^)), '║');
  WriteLn('║ WebSocket:    ', BoolToStr(Config.EnableWebSocket, True),
         StringOfChar(' ', 35 - Length(BoolToStr(Config.EnableWebSocket, True))), '║');
  WriteLn('║ CORS:         ', BoolToStr(Config.EnableCORS, True),
         StringOfChar(' ', 35 - Length(BoolToStr(Config.EnableCORS, True))), '║');
  WriteLn('╚══════════════════════════════════════════════════════╝');
  WriteLn('');

  WriteLn('=== JWT Authentication ===');
  WriteLn('Algorithm: HS256');
  WriteLn('Expiration: ', Config.JWTExpirationMinutes, ' minutes');

  if Config.JWTSecret = 'ChangeThisToAStrongSecretKeyAtLeast32Chars' then
  begin
    WriteLn('');
    WriteLn('⚠️  WARNING: Using default JWT secret!');
    WriteLn('    Change JWTSecret in ', CONFIG_FILENAME, ' for production!');
  end;

  WriteLn('');
  WriteLn('=== Default Admin Account ===');
  WriteLn('Username: ', DEFAULT_ADMIN_USERNAME);
  WriteLn('Password: ', DEFAULT_ADMIN_PASSWORD);
  WriteLn('Email: ', DEFAULT_ADMIN_EMAIL);
  WriteLn('');

  WriteLn('=== Available Endpoints ===');
  WriteLn('POST /root/auth/login      - User login (get JWT token)');
  WriteLn('POST /root/auth/verify     - Verify JWT token');
  WriteLn('POST /root/auth/refresh    - Refresh JWT token');
  WriteLn('GET  /root/blog/Posts      - Get blog posts list');
  WriteLn('GET  /root/blog/Post/{id}  - Get single blog post');
  WriteLn('POST /root/blog/Post       - Create post (requires JWT)');
  WriteLn('PUT  /root/blog/Post/{id}  - Update post (requires JWT)');
  WriteLn('DELETE /root/blog/Post/{id} - Delete post (requires JWT)');
  WriteLn('');

  WriteLn('=== Quick Test Commands ===');
  WriteLn('1. Login:');
  WriteLn('   curl -X POST http://localhost:', Config.Port, '/root/auth/login ^');
  WriteLn('     -H "Content-Type: application/json" ^');
  WriteLn('     -d "{\"Username\":\"admin\",\"Password\":\"admin123\"}"');
  WriteLn('');
  WriteLn('2. Get all posts:');
  WriteLn('   curl http://localhost:', Config.Port, '/root/blog/Posts');
  WriteLn('');
  WriteLn('=== Server Controls ===');
  WriteLn('Press Ctrl+C to stop server gracefully');
  WriteLn('');
end;

procedure SetupLogging;
begin
  // 创建日志实例
  Logger := TSynLog.Add;

  // 设置日志级别
  TSynLog.Family.Level := [sllError, sllInfo, sllDebug];

  // 设置文件日志
  TSynLog.Family.PerThreadLog := ptIdentifiedInOneFile;
  TSynLog.Family.HighResolutionTimestamp := True;
  TSynLog.Family.RotateFileCount := 10;
  TSynLog.Family.RotateFileSizeKB := 100 * 1024; // 100MB

  // 同时输出到控制台
  TSynLog.Family.EchoToConsole := LOG_VERBOSE;
end;

begin
  try
    // 加载配置
    Config.LoadFromFile(CONFIG_FILENAME);

    // 设置日志
    SetupLogging;

    Logger.Log(sllInfo, 'Starting Blog Server...');

    // 设置控制台中断处理
    SetupConsoleCtrlHandler;

    // 1. 创建ORM模型
    Model := TOrmModel.Create([TUser, TBlogPost, TComment,TImage,TPostImage], 'root');
    Logger.Log(sllInfo, 'ORM Model created with % tables', [Model.Tables]);

    // 2. 创建SQLite数据库和REST服务器
    Logger.Log(sllInfo, 'Creating database: %', [Config.DatabaseFile]);

    // 在 mORMot 2 中创建 TRestServerDB 的正确方式
    RestServer := TRestServerDB.Create(Model, Config.DatabaseFile);

    // 创建缺失的表
    RestServer.Server.CreateMissingTables;
    Logger.Log(sllInfo, 'Database tables created/verified');

    // 3. 初始化数据库
    InitializeDatabase;

    // 4. 注册服务
    var BlogService: TBlogService := nil;
    try
      BlogService := TBlogService.Create(RestServer);
      RestServer.ServiceDefine(BlogService, [IBlogService]);
      Logger.Log(sllInfo, 'Blog service registered');

      // 5. 配置并启动HTTP服务器
      HttpServer := TRestHttpServer.Create([RestServer], Config.Port);

      EnsureUploadsDirectory;

      // 创建静态文件处理器
      StaticFileHandler := TStaticFileHandler.Create(
        ExtractFilePath(ParamStr(0)) + 'uploads');

      // 设置自定义请求处理器 - 使用对象方法
      HttpServer.OnCustomRequest := StaticFileHandler.HandleRequest;

      // 设置 CORS
      HttpServer.AccessControlAllowOrigin := '*';

      if Config.EnableWebSocket then
      begin
        HttpServer.WebSocketsEnable(RestServer, 'ws');
        Logger.Log(sllInfo, 'WebSocket enabled at /ws');
      end;

      // 设置JWT密钥
      if Config.JWTSecret <> 'D260D22C-0E20-4B2F-8DE1-87BCEA08D316' then
      begin
        // 这里需要根据您的 JWT 实现来设置密钥
        // 通常使用 TJWTAuth 类
        Logger.Log(sllInfo, 'JWT secret configured');
      end
      else
      begin
        Logger.Log(sllWarning, 'Using default JWT secret - CHANGE IN PRODUCTION!');
      end;

      // 显示服务器信息
      ShowServerInfo;

      Logger.Log(sllInfo, 'Server started successfully on port %', [Config.Port]);
      Logger.Log(sllInfo, 'Press Ctrl+C to stop server');

      // 6. 主循环 - 等待退出信号
      while not ShutdownRequested do
      begin
        Sleep(100);
      end;

      // 7. 优雅关闭
      Logger.Log(sllInfo, 'Shutting down server...');

    except
      on E: Exception do
      begin
        Logger.Log(sllError, 'Server error: %', [E.Message]);
        WriteLn('');
        WriteLn('❌ Error: ', E.ClassName, ': ', E.Message);
        WriteLn('');
        WriteLn('Press Enter to exit...');
        ReadLn;
      end;
    end;

  finally
    // 清理资源
    if Assigned(HttpServer) then
    begin
      HttpServer.Shutdown;
      Sleep(100);
      FreeAndNil(HttpServer);
      Logger.Log(sllInfo, 'HTTP server stopped');
    end;

//    if Assigned(BlogService) then
//      FreeAndNil(BlogService);

    if Assigned(RestServer) then
    begin
      FreeAndNil(RestServer);
      Logger.Log(sllInfo, 'REST server stopped');
    end;

    if Assigned(Model) then
      FreeAndNil(Model);

    Logger.Log(sllInfo, 'Blog Server shutdown complete');

    // 保存配置(如果有修改)
    Config.SaveToFile(CONFIG_FILENAME);
  end;
end.

unit Blog.Service;

interface

uses
  System.SysUtils,
  mormot.core.base,
  mormot.core.data,
  mormot.core.json,
  mormot.core.interfaces,
  mormot.orm.core,
  mormot.rest.core,
  mormot.rest.server,
  mormot.core.variants,
  mormot.core.text,
  mormot.core.os,
  mormot.crypt.core,
  mormot.core.buffers,
  mormot.core.datetime,  // 添加这个单元以使用 DateTimeToIso8601
  Blog.Model,
  system.Classes,
  System.Generics.Collections,
  Blog.JWT;

type
  /// <summary>博客服务接口</summary>
  IBlogService = interface(IInvokable)
    ['{B8F5E3D2-1A4C-4B8E-9D6F-3C2A5B7D8E9F}']

    /// <summary>用户登录</summary>
    function Login(const Username, Password: RawUTF8): RawJSON;

    /// <summary>验证JWT令牌</summary>
    function VerifyToken(const Token: RawUTF8): RawJSON;

    /// <summary>获取文章列表</summary>
    function GetPosts(const PublishedOnly: Boolean = True): RawJSON;

    /// <summary>获取单篇文章</summary>
    function GetPost(ID: TID): RawJSON;

    /// <summary>创建文章</summary>
    function CreatePost(const Token, Title, Content, Summary, Author: RawUTF8;
      Published: Boolean): RawJSON;

    /// <summary>更新文章</summary>
    function UpdatePost(const Token: RawUTF8; ID: TID;
      const Title, Content, Summary, Author: RawUTF8; Published: Boolean): RawJSON;

    /// <summary>删除文章</summary>
    function DeletePost(const Token: RawUTF8; ID: TID): RawJSON;

    /// <summary>获取文章评论</summary>
    function GetComments(PostID: TID): RawJSON;

    /// <summary>创建评论</summary>
    function CreateComment(PostID: TID; const AuthorName, AuthorEmail, Content: RawUTF8): RawJSON;

    /// <summary>批准评论</summary>
    function ApproveComment(const Token: RawUTF8; ID: TID): RawJSON;

    /// <summary>删除评论</summary>
    function DeleteComment(const Token: RawUTF8; ID: TID): RawJSON;

    /// <summary>刷新令牌</summary>
    function RefreshToken(const Token: RawUTF8): RawJSON;

    /// <summary>上传图片</summary>
    function UploadImage(const Token, FileName, MimeType: RawUTF8;
      const ImageData: RawByteString): RawJSON;

    /// <summary>创建带图片的文章</summary>
    function CreatePostWithImages(const Token, Title, Content, Summary, Author: RawUTF8;
      Published: Boolean; const ImageIDs: TIDDynArray; CoverImageID: TID = 0): RawJSON;

    /// <summary>为文章添加图片</summary>
    function AddImagesToPost(const Token: RawUTF8; PostID: TID;
      const ImageIDs: TIDDynArray): RawJSON;

    /// <summary>获取文章的图片列表</summary>
    function GetPostImages(PostID: TID): RawJSON;

    /// <summary>删除图片</summary>
    function DeleteImage(const Token: RawUTF8; ImageID: TID): RawJSON;

    function DownloadImage(ImageID: TID): RawByteString;
  end;

  /// <summary>博客服务实现</summary>
  TBlogService = class(TInterfacedObject, IBlogService)
  private
    fServer: TRestServer;
    fJWT: TBlogJWT;
    function GetUserID(const Username: RawUTF8): TID;
    function GetUser(const Username: RawUTF8): TUser;
    function VerifyUserToken(const Token: RawUTF8; out UserID: TID;
      out Username: RawUTF8): Boolean;
    function SuccessResponse(const Data: RawJSON = 'null'): RawJSON;
    function ErrorResponse(const ErrorMsg: RawUTF8): RawJSON;
  public
    constructor Create(Server: TRestServer);
    destructor Destroy; override;

    function Login(const Username, Password: RawUTF8): RawJSON;
    function VerifyToken(const Token: RawUTF8): RawJSON;
    function GetPosts(const PublishedOnly: Boolean): RawJSON;
    function GetPost(ID: TID): RawJSON;
    function CreatePost(const Token, Title, Content, Summary, Author: RawUTF8;
      Published: Boolean): RawJSON;
    function UpdatePost(const Token: RawUTF8; ID: TID;
      const Title, Content, Summary, Author: RawUTF8; Published: Boolean): RawJSON;
    function DeletePost(const Token: RawUTF8; ID: TID): RawJSON;
    function GetComments(PostID: TID): RawJSON;
    function CreateComment(PostID: TID; const AuthorName, AuthorEmail, Content: RawUTF8): RawJSON;
    function ApproveComment(const Token: RawUTF8; ID: TID): RawJSON;
    function DeleteComment(const Token: RawUTF8; ID: TID): RawJSON;
    function RefreshToken(const Token: RawUTF8): RawJSON;
    /// <summary>上传图片</summary>
    function UploadImage(const Token, FileName, MimeType: RawUTF8;
      const ImageData: RawByteString): RawJSON;

    /// <summary>创建带图片的文章</summary>
    function CreatePostWithImages(const Token, Title, Content, Summary, Author: RawUTF8;
      Published: Boolean; const ImageIDs: TIDDynArray; CoverImageID: TID = 0): RawJSON;

    /// <summary>为文章添加图片</summary>
    function AddImagesToPost(const Token: RawUTF8; PostID: TID;
      const ImageIDs: TIDDynArray): RawJSON;

    /// <summary>获取文章的图片列表</summary>
    function GetPostImages(PostID: TID): RawJSON;

    /// <summary>删除图片</summary>
    function DeleteImage(const Token: RawUTF8; ImageID: TID): RawJSON;

    function DownloadImage(ImageID: TID): RawByteString;
  end;

  function HashPassword(const Password: RawUtf8): RawUtf8;

implementation

{ TBlogService }


function SanitizeFileName(const FileName: RawUTF8): RawUTF8;
var
  i: Integer;
begin
  Result := FileName;
  // 只过滤绝对不允许的字符,保留中文(大于#127的字符)
  for i := 1 to Length(Result) do
  begin
    // ASCII控制字符和路径分隔符
    if Ord(Result[i]) < 32 then
      Result[i] := '_'
    else if Result[i] in ['\', '/', ':', '*', '?', '"', '<', '>', '|'] then
      Result[i] := '_';
    // 其他字符(包括中文)全部保留
  end;

  // 如果全部被过滤了,给个默认名
  if Result = '' then
    Result := 'image_' + IntToStr(UnixTimeUTC);
end;

function GenerateUniqueFileName(const OriginalName: RawUTF8): RawUTF8;
begin
  // 格式: 时间戳_随机数_安全文件名
  Result := FormatUTF8('%-%-%',
    [UnixTimeUTC, Random32, SanitizeFileName(OriginalName)]);
end;

function HashPassword(const Password: RawUtf8): RawUtf8;
begin
  Result := Sha256('BlogServerSalt_' + Password);
end;

constructor TBlogService.Create(Server: TRestServer);
begin
  inherited Create;
  fServer := Server;
  fJWT := TBlogJWT.Create('D260D22C-0E20-4B2F-8DE1-87BCEA08D316');
end;

destructor TBlogService.Destroy;
begin
  fJWT.Free;
  inherited;
end;

function TBlogService.GetUserID(const Username: RawUTF8): TID;
begin
  Result := fServer.Orm.OneFieldValueInt64(TUser, 'ID',
    FormatUTF8('Username=?', [], [Username]));
end;

function TBlogService.GetUser(const Username: RawUTF8): TUser;
var
  UserID: TID;
begin
  Result := nil;
  UserID := GetUserID(Username);
  if UserID > 0 then
  begin
    Result := TUser.Create;
    if not fServer.Orm.Retrieve(UserID, Result) then
      FreeAndNil(Result);
  end;
end;

function TBlogService.VerifyUserToken(const Token: RawUTF8; out UserID: TID;
  out Username: RawUTF8): Boolean;
begin
  Result := fJWT.VerifyToken(Token, UserID, Username);
end;

function TBlogService.SuccessResponse(const Data: RawJSON): RawJSON;
begin
  Result := FormatUTF8('{"success":true,"data":%}', [Data]);
end;

function TBlogService.ErrorResponse(const ErrorMsg: RawUTF8): RawJSON;
begin
  Result := FormatUTF8('{"success":false,"error":%}', [QuotedStrJson(ErrorMsg)]);
end;

function TBlogService.Login(const Username, Password: RawUTF8): RawJSON;
var
  User: TUser;
begin
  // 验证参数
  if (Username = '') or (Password = '') then
    Exit(ErrorResponse('Username and password are required'));

  User := GetUser(Username);
  if not Assigned(User) then
    Exit(ErrorResponse('User not found'));

  try
    // 验证密码
    if User.Password = HashPassword(Password) then
    begin
      // 生成JWT令牌
      var Token := fJWT.GenerateToken(User);
      var Data := FormatUTF8('{"token":%,"user_id":%,"username":%,"expires_in":3600}',
        [QuotedStrJson(Token), User.ID, QuotedStrJson(User.Username)]);
      Result := SuccessResponse(Data);
    end
    else
    begin
      Result := ErrorResponse('Invalid password');
    end;
  finally
    User.Free;
  end;
end;

function TBlogService.VerifyToken(const Token: RawUTF8): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
begin
  if Token = '' then
    Exit(ErrorResponse('Token is required'));

  var Valid := fJWT.VerifyToken(Token, UserID, Username);
  if Valid then
  begin
    var Data := FormatUTF8('{"valid":true,"user_id":%,"username":%}',
      [UserID, QuotedStrJson(Username)]);
    Result := SuccessResponse(Data);
  end
  else
  begin
    var Data := '{"valid":false}';
    Result := SuccessResponse(Data);
  end;
end;

function TBlogService.RefreshToken(const Token: RawUTF8): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  User: TUser;
begin
  if Token = '' then
    Exit(ErrorResponse('Token is required'));

  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  User := GetUser(Username);
  if not Assigned(User) then
    Exit(ErrorResponse('User not found'));

  try
    // 生成新令牌
    var NewToken := fJWT.GenerateToken(User);
    var Data := FormatUTF8('{"token":%,"user_id":%,"username":%,"expires_in":3600}',
      [QuotedStrJson(NewToken), User.ID, QuotedStrJson(User.Username)]);
    Result := SuccessResponse(Data);
  finally
    User.Free;
  end;
end;

function TBlogService.GetPosts(const PublishedOnly: Boolean): RawJSON;
begin
  var JsonData: RawJSON;
  if PublishedOnly then
    JsonData := fServer.Orm.RetrieveListJson(TBlogPost, 'Published=1')
  else
    JsonData := fServer.Orm.RetrieveListJson(TBlogPost, '');

  Result := SuccessResponse(JsonData);
end;

function TBlogService.GetPost(ID: TID): RawJSON;
var
  Post: TBlogPost;
begin
  if ID = 0 then
    Exit(ErrorResponse('Invalid post ID'));

  Post := TBlogPost.Create;
  try
    if fServer.Orm.Retrieve(ID, Post) then
    begin
      var Data := ObjectToJson(Post);
      Result := SuccessResponse(Data);
    end
    else
    begin
      Result := ErrorResponse('Post not found');
    end;
  finally
    Post.Free;
  end;
end;

function TBlogService.CreatePost(const Token, Title, Content, Summary, Author: RawUTF8;
  Published: Boolean): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  Post: TBlogPost;
begin
  // 验证参数
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if Title = '' then
    Exit(ErrorResponse('Title is required'));
  if Content = '' then
    Exit(ErrorResponse('Content is required'));

  // 验证令牌
  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  Post := TBlogPost.Create;
  try
    Post.Title := Title;
    Post.Content := Content;
    Post.Summary := Summary;
    Post.Author := Author;
    Post.Published := Published;
    Post.CreatedAt := NowUTC;
    Post.UpdatedAt := Post.CreatedAt;

    var PostID := fServer.Orm.Add(Post, True);
    if PostID > 0 then
    begin
      // 使用 DateTimeToIso8601 转换时间
      var CreatedAtStr := DateTimeToIso8601(Post.CreatedAt, true, 'T', false);
      var Data := FormatUTF8('{"id":%,"title":%,"author":%,"published":%,"created_at":%}',
        [PostID,
         QuotedStrJson(Title),
         QuotedStrJson(Author),
         BoolToStr(Published, True),
         QuotedStrJson(CreatedAtStr)]);
      Result := SuccessResponse(Data);
    end
    else
    begin
      Result := ErrorResponse('Failed to create post');
    end;
  finally
    Post.Free;
  end;
end;

function TBlogService.UpdatePost(const Token: RawUTF8; ID: TID;
  const Title, Content, Summary, Author: RawUTF8; Published: Boolean): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  Post: TBlogPost;
begin
  // 验证参数
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if ID = 0 then
    Exit(ErrorResponse('Invalid post ID'));
  if Title = '' then
    Exit(ErrorResponse('Title is required'));
  if Content = '' then
    Exit(ErrorResponse('Content is required'));

  // 验证令牌
  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  Post := TBlogPost.Create;
  try
    if not fServer.Orm.Retrieve(ID, Post) then
      Exit(ErrorResponse('Post not found'));

    Post.Title := Title;
    Post.Content := Content;
    Post.Summary := Summary;
    Post.Author := Author;
    Post.Published := Published;
    Post.UpdatedAt := NowUTC;

    var Updated := fServer.Orm.Update(Post);
    if Updated then
    begin
      var Data := FormatUTF8('{"id":%,"title":%,"updated":true}',
        [ID, QuotedStrJson(Title)]);
      Result := SuccessResponse(Data);
    end
    else
    begin
      Result := ErrorResponse('Failed to update post');
    end;
  finally
    Post.Free;
  end;
end;

function TBlogService.DeletePost(const Token: RawUTF8; ID: TID): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
begin
  // 验证参数
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if ID = 0 then
    Exit(ErrorResponse('Invalid post ID'));

  // 验证令牌
  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  var Deleted := fServer.Orm.Delete(TBlogPost, ID);
  if Deleted then
  begin
    var Data := FormatUTF8('{"id":%,"deleted":true}', [ID]);
    Result := SuccessResponse(Data);
  end
  else
  begin
    Result := ErrorResponse('Failed to delete post');
  end;
end;

function TBlogService.GetComments(PostID: TID): RawJSON;
begin
  if PostID = 0 then
    Exit(ErrorResponse('Invalid post ID'));

  var JsonData := fServer.Orm.RetrieveListJson(TComment,
    FormatUTF8('PostID=? AND Approved=1', [PostID]));

  Result := SuccessResponse(JsonData);
end;

function TBlogService.CreateComment(PostID: TID; const AuthorName, AuthorEmail, Content: RawUTF8): RawJSON;
var
  Comment: TComment;
begin
  // 验证参数
  if PostID = 0 then
    Exit(ErrorResponse('Invalid post ID'));
  if AuthorName = '' then
    Exit(ErrorResponse('Author name is required'));
  if Content = '' then
    Exit(ErrorResponse('Content is required'));

  Comment := TComment.Create;
  try
    Comment.PostID := PostID;
    Comment.AuthorName := AuthorName;
    Comment.AuthorEmail := AuthorEmail;
    Comment.Content := Content;
    Comment.CreatedAt := NowUTC;
    Comment.Approved := True;

    var CommentID := fServer.Orm.Add(Comment, True);
    if CommentID > 0 then
    begin
      var CreatedAtStr := DateTimeToIso8601(Comment.CreatedAt, true, 'T', false);
      var Data := FormatUTF8('{"id":%,"author":%,"content":%,"created_at":%}',
        [CommentID,
         QuotedStrJson(AuthorName),
         QuotedStrJson(Copy(Content, 1, 100)), // 截取前100个字符
         QuotedStrJson(CreatedAtStr)]);
      Result := SuccessResponse(Data);
    end
    else
    begin
      Result := ErrorResponse('Failed to create comment');
    end;
  finally
    Comment.Free;
  end;
end;

function TBlogService.ApproveComment(const Token: RawUTF8; ID: TID): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  Comment: TComment;
begin
  // 验证参数
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if ID = 0 then
    Exit(ErrorResponse('Invalid comment ID'));

  // 验证令牌
  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  Comment := TComment.Create;
  try
    if not fServer.Orm.Retrieve(ID, Comment) then
      Exit(ErrorResponse('Comment not found'));

    Comment.Approved := True;
    var Updated := fServer.Orm.Update(Comment);
    if Updated then
    begin
      var Data := FormatUTF8('{"id":%,"approved":true}', [ID]);
      Result := SuccessResponse(Data);
    end
    else
    begin
      Result := ErrorResponse('Failed to approve comment');
    end;
  finally
    Comment.Free;
  end;
end;

function TBlogService.DeleteComment(const Token: RawUTF8; ID: TID): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
begin
  // 验证参数
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if ID = 0 then
    Exit(ErrorResponse('Invalid comment ID'));

  // 验证令牌
  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  var Deleted := fServer.Orm.Delete(TComment, ID);
  if Deleted then
  begin
    var Data := FormatUTF8('{"id":%,"deleted":true}', [ID]);
    Result := SuccessResponse(Data);
  end
  else
  begin
    Result := ErrorResponse('Failed to delete comment');
  end;
end;


function TBlogService.UploadImage(const Token, FileName, MimeType: RawUTF8;
  const ImageData: RawByteString): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  Image: TImage;
  SavePath, SaveDir, AppPath, WebUrl, UrlFileName: RawUTF8;
  fs: TFileStream;
  ValidImage: Boolean;
begin
  // 验证参数
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if FileName = '' then
    Exit(ErrorResponse('File name is required'));
  if Length(ImageData) = 0 then
    Exit(ErrorResponse('Image data is empty'));

  // 验证令牌
  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  // 验证文件类型
  ValidImage := (PosEx('image/', MimeType) = 1) and
                ((PosEx('jpeg', MimeType) > 0) or
                 (PosEx('png', MimeType) > 0) or
                 (PosEx('gif', MimeType) > 0) or
                 (PosEx('webp', MimeType) > 0));

  if not ValidImage then
    Exit(ErrorResponse('Only JPEG, PNG, GIF and WebP images are allowed'));

  // 获取应用程序路径
  AppPath := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0)));

  // 按日期组织目录,避免单个目录文件过多
  var DatePath := FormatDateTime('yyyy-mm-dd', NowUTC);

  // 创建上传目录:uploads/images/用户名/日期/
  SaveDir := FormatUTF8('%uploads\images\%\%\',
    [AppPath, Username, DatePath]);
  ForceDirectories(Utf8ToString(SaveDir));

  // 生成唯一文件名:保留原始文件名,但添加时间戳前缀避免重名
  var TimeStamp := IntToStr(UnixTimeUTC);
  var RandomStr := IntToHex(Random32, 8);

  // 关键:不要过滤中文!只过滤非法字符
  var SafeFileName := SanitizeFileName(FileName);

  // 添加时间戳和随机数,避免重名,同时保留中文名
  var UniqueFileName := FormatUTF8('%-%_%', [TimeStamp, RandomStr, SafeFileName]);
  SavePath := SaveDir + UniqueFileName;

  // 保存原始图片
  fs := TFileStream.Create(Utf8ToString(SavePath), fmCreate);
  try
    fs.WriteBuffer(Pointer(ImageData)^, Length(ImageData));
  finally
    fs.Free;
  end;

  // 生成Web访问URL - 关键:对文件名进行URL编码!
  // 只对文件名部分编码,不对路径编码
  UrlFileName := urlencode(UniqueFileName);
  WebUrl := FormatUTF8('/static/images/%/%/%',
    [Username, DatePath, UrlFileName]);

  // 保存图片信息到数据库
  Image := TImage.Create;
  try
    Image.FileName := SafeFileName;        // 存储原始文件名(已过滤非法字符但保留中文)
    Image.FileSize := Length(ImageData);
    Image.MimeType := MimeType;
    Image.StoragePath := SavePath;         // 磁盘完整路径
    Image.UploadedBy := UserID;
    Image.UploadedAt := NowUTC;
    // Image.ThumbnailPath := '';         // 缩略图暂不实现

    var ImageID := fServer.Orm.Add(Image, True);
    if ImageID > 0 then
    begin
      var Data := FormatUTF8(
        '{"id":%,"filename":%,"url":%,"size":%,"mime_type":%,"upload_time":"%"}',
        [ImageID,
         QuotedStrJson(SafeFileName),      // 返回原始文件名
         QuotedStrJson(WebUrl),            // 返回编码后的URL
         Length(ImageData),
         QuotedStrJson(MimeType),
         DateTimeToIso8601(NowUTC, True)]);
      Result := SuccessResponse(Data);
    end
    else
    begin
      // 数据库保存失败,删除已保存的文件
      DeleteFile(Utf8ToString(SavePath));
      Result := ErrorResponse('Failed to save image record');
    end;
  finally
    Image.Free;
  end;
end;

function TBlogService.CreatePostWithImages(const Token, Title, Content, Summary, Author: RawUTF8;
  Published: Boolean; const ImageIDs: TIDDynArray; CoverImageID: TID): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  Post: TBlogPost;
  PostImage: TPostImage;
  i: Integer;
begin
  // 验证参数(复用原有的参数验证)
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if Title = '' then
    Exit(ErrorResponse('Title is required'));
  if Content = '' then
    Exit(ErrorResponse('Content is required'));

  // 验证令牌
  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  // 创建文章
  Post := TBlogPost.Create;
  try
    Post.Title := Title;
    Post.Content := Content;
    Post.Summary := Summary;
    Post.Author := Author;
    Post.Published := Published;
    Post.CreatedAt := NowUTC;
    Post.UpdatedAt := Post.CreatedAt;
    Post.CoverImageID := CoverImageID;

    var PostID := fServer.Orm.Add(Post, True);
    if PostID = 0 then
      Exit(ErrorResponse('Failed to create post'));

    // 关联图片
    for i := 0 to High(ImageIDs) do
    begin
      if ImageIDs[i] > 0 then
      begin
        PostImage := TPostImage.Create;
        try
          PostImage.PostID := PostID;
          PostImage.ImageID := ImageIDs[i];
          PostImage.SortOrder := i + 1;
          fServer.Orm.Add(PostImage, True);
        finally
          PostImage.Free;
        end;
      end;
    end;

    var Data := FormatUTF8('{"id":%,"title":%,"author":%,"published":%,"image_count":%}',
      [PostID,
       QuotedStrJson(Title),
       QuotedStrJson(Author),
       BoolToStr(Published, True),
       Length(ImageIDs)]);
    Result := SuccessResponse(Data);
  finally
    Post.Free;
  end;
end;

function TBlogService.AddImagesToPost(const Token: RawUTF8; PostID: TID;
  const ImageIDs: TIDDynArray): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  PostImage: TPostImage;
  i, AddedCount: Integer;
  CurrentCount: Int64;
begin
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if PostID = 0 then
    Exit(ErrorResponse('Invalid post ID'));

  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  // 验证文章是否存在
  if not fServer.Orm.MemberExists(TBlogPost, PostID) then
    Exit(ErrorResponse('Post not found'));

  // 方法1:使用 OneFieldValueInt64(返回 Int64)
  CurrentCount := fServer.Orm.OneFieldValueInt64(TPostImage, 'COUNT(*)',
    'PostID=' + IntToStr(PostID));

  AddedCount := 0;

  // 添加新图片
  for i := 0 to High(ImageIDs) do
  begin
    if (ImageIDs[i] > 0) and fServer.Orm.MemberExists(TImage, ImageIDs[i]) then
    begin
      PostImage := TPostImage.Create;
      try
        PostImage.PostID := PostID;
        PostImage.ImageID := ImageIDs[i];
        PostImage.SortOrder := Integer(CurrentCount) + AddedCount + 1;
        if fServer.Orm.Add(PostImage, True) > 0 then
          Inc(AddedCount);
      finally
        PostImage.Free;
      end;
    end;
  end;

  if AddedCount = 0 then
    Exit(ErrorResponse('No valid images were added'));

  var Data := FormatUTF8('{"post_id":%,"added_count":%}', [PostID, AddedCount]);
  Result := SuccessResponse(Data);
end;


function PathToUrl(const StoragePath: RawUTF8): RawUTF8;
var
  RelativePath: RawUTF8;
  FileName: RawUTF8;
  LastSlash: Integer;
begin
  Result := '';

  // 1. 找到 'uploads\' 的位置
  var PosUploads := Pos('uploads\', StoragePath);
  if PosUploads = 0 then
    PosUploads := Pos('uploads/', StoragePath);

  if PosUploads > 0 then
  begin
    // 2. 提取相对路径(从 uploads 开始)
    RelativePath := Copy(StoragePath, PosUploads, MaxInt);

    // 3. 反斜杠转正斜杠
    RelativePath := StringReplace(RelativePath, '\', '/', [rfReplaceAll]);

    // 4. 分离路径和文件名
    LastSlash := LastDelimiter('/', RelativePath);
    if LastSlash > 0 then
    begin
      // 路径部分保持原样,文件名部分需要 URL 编码
      var PathPart := Copy(RelativePath, 1, LastSlash);
      var FilePart := Copy(RelativePath, LastSlash + 1, MaxInt);

      // 5. 构建 Web URL:/static/路径/编码后的文件名
      Result := '/static/' + PathPart + UrlEncode(FilePart);
    end
    else
    begin
      // 没有路径分隔符,整个文件名编码
      Result := '/static/' + UrlEncode(RelativePath);
    end;
  end;
end;

function TBlogService.GetPostImages(PostID: TID): RawJSON;
var
  Table: TOrmTable;
  JsonData: RawUTF8;
  i: Integer;
  StoragePath, WebUrl: RawUTF8;
begin
  if PostID = 0 then
    Exit(ErrorResponse('Invalid post ID'));

  try
    // 构建完整 SQL
    var SQL := FormatUTF8(
      'SELECT i.* FROM % i ' +
      'INNER JOIN % pi ON i.ID = pi.ImageID ' +
      'WHERE pi.PostID = % ' +
      'ORDER BY pi.SortOrder',
      [TImage.SqlTableName, TPostImage.SqlTableName, PostID]);

    // 使用 ExecuteList 获取结果表
    Table := fServer.Orm.ExecuteList([TImage, TPostImage], SQL);

    if not Assigned(Table) then
      Exit(SuccessResponse('[]'));

    try
      if Table.RowCount = 0 then
        Exit(SuccessResponse('[]'));

      // 获取原始的 JSON 数据
      JsonData := Table.GetJsonValues(True);

      // 将 JSON 字符串转换为 TDocVariant 进行处理
      var JsonDoc := _JsonFast(JsonData);
      var ArrayDoc := TDocVariantData(JsonDoc);

      // 确保是数组
//      if JsonDoc.Kind = dvArray then
      begin


        // 遍历数组中的每个对象
        for i := 0 to ArrayDoc.Count - 1 do
        begin
          var Item := ArrayDoc[i];
          var ItemData := DocVariantData(Item);

          // 获取 StoragePath 字段
          var StoragePathVar := ItemData.U['StoragePath'];
          if StoragePathVar <> '' then
          begin
            StoragePath := StoragePathVar;
            // 转换为 Web URL
            WebUrl := PathToUrl(StoragePath);
            // 添加或替换 url 字段
            ItemData.AddOrUpdateValue('url', WebUrl);
             ItemData.Delete('StoragePath');
          end;
        end;
      end;

      Result := SuccessResponse(ArrayDoc.ToJson);

    finally
      Table.Free;
    end;
  except
    on E: Exception do
      Result := ErrorResponse('Failed to get images: ' + E.Message);
  end;
end;

function TBlogService.DeleteImage(const Token: RawUTF8; ImageID: TID): RawJSON;
var
  UserID: TID;
  Username: RawUTF8;
  Image: TImage;
begin
  if Token = '' then
    Exit(ErrorResponse('Token is required'));
  if ImageID = 0 then
    Exit(ErrorResponse('Invalid image ID'));

  if not VerifyUserToken(Token, UserID, Username) then
    Exit(ErrorResponse('Invalid or expired token'));

  Image := TImage.Create;
  try
    if not fServer.Orm.Retrieve(ImageID, Image) then
      Exit(ErrorResponse('Image not found'));

    // 验证权限:只有上传者可以删除
    if Image.UploadedBy <> UserID then
      Exit(ErrorResponse('Permission denied'));

    // 删除物理文件
    if FileExists(Image.StoragePath) then
      DeleteFile(Image.StoragePath);
    if FileExists(Image.ThumbnailPath) then
      DeleteFile(Image.ThumbnailPath);

    // 删除数据库记录
    var Deleted := fServer.Orm.Delete(TImage, ImageID);
    if Deleted then
    begin
      // 同时删除关联关系
      fServer.Orm.Delete(TPostImage, 'ImageID=?', [ImageID]);

      var Data := FormatUTF8('{"id":%,"deleted":true}', [ImageID]);
      Result := SuccessResponse(Data);
    end
    else
    begin
      Result := ErrorResponse('Failed to delete image');
    end;
  finally
    Image.Free;
  end;
end;


function TBlogService.DownloadImage(ImageID: TID): RawByteString;
var
  Image: TImage;
  fs: TFileStream;
begin
  if ImageID = 0 then
    Exit('');

  Image := TImage.Create;
  try
    if not fServer.Orm.Retrieve(ImageID, Image) then
      Exit('');

    // 检查文件是否存在
    if not FileExists(Image.StoragePath) then
      Exit('');

    // 读取文件内容
    fs := TFileStream.Create(Utf8ToString(Image.StoragePath), fmOpenRead or fmShareDenyNone);
    try
      SetLength(Result, fs.Size);
      fs.ReadBuffer(Pointer(Result)^, fs.Size);
    finally
      fs.Free;
    end;
  finally
    Image.Free;
  end;
end;

initialization
  TInterfaceFactory.RegisterInterfaces([TypeInfo(IBlogService)]);

end.

unit Blog.Model;

interface

uses
  mormot.core.base,
  mormot.orm.core;

type
  /// <summary>用户模型</summary>
  TUser = class(TOrm)
  private
    fUsername: RawUTF8;
    fPassword: RawUTF8;
    fEmail: RawUTF8;
    fCreatedAt: TDateTime;
  published
    /// <summary>用户名</summary>
    property Username: RawUTF8 index 50 read fUsername write fUsername;
    /// <summary>密码哈希</summary>
    property Password: RawUTF8 index 64 read fPassword write fPassword;
    /// <summary>邮箱</summary>
    property Email: RawUTF8 index 100 read fEmail write fEmail;
    /// <summary>创建时间</summary>
    property CreatedAt: TDateTime read fCreatedAt write fCreatedAt;
  end;

  /// <summary>博客文章模型</summary>
  TBlogPost = class(TOrm)
  private
    fTitle: RawUTF8;
    fContent: RawUTF8;
    fSummary: RawUTF8;
    fAuthor: RawUTF8;
    fPublished: Boolean;
    fCreatedAt: TDateTime;
    fUpdatedAt: TDateTime;
    fCoverImageID: TID;  // 封面图片ID
  published
    /// <summary>文章标题</summary>
    property Title: RawUTF8 index 200 read fTitle write fTitle;
    /// <summary>文章内容</summary>
    property Content: RawUTF8 read fContent write fContent;
    /// <summary>文章摘要</summary>
    property Summary: RawUTF8 index 500 read fSummary write fSummary;
    /// <summary>作者</summary>
    property Author: RawUTF8 index 100 read fAuthor write fAuthor;
    /// <summary>是否发布</summary>
    property Published: Boolean read fPublished write fPublished;
    /// <summary>创建时间</summary>
    property CreatedAt: TDateTime read fCreatedAt write fCreatedAt;
    /// <summary>更新时间</summary>
    property UpdatedAt: TDateTime read fUpdatedAt write fUpdatedAt;
    /// <summary>封面图片ID</summary>
    property CoverImageID: TID read fCoverImageID write fCoverImageID;
  end;

  /// <summary>评论模型</summary>
  TComment = class(TOrm)
  private
    fPostID: TID;
    fAuthorName: RawUTF8;
    fAuthorEmail: RawUTF8;
    fContent: RawUTF8;
    fCreatedAt: TDateTime;
    fApproved: Boolean;
  published
    /// <summary>关联的文章ID</summary>
    property PostID: TID read fPostID write fPostID;
    /// <summary>评论者姓名</summary>
    property AuthorName: RawUTF8 index 50 read fAuthorName write fAuthorName;
    /// <summary>评论者邮箱</summary>
    property AuthorEmail: RawUTF8 index 100 read fAuthorEmail write fAuthorEmail;
    /// <summary>评论内容</summary>
    property Content: RawUTF8 read fContent write fContent;
    /// <summary>创建时间</summary>
    property CreatedAt: TDateTime read fCreatedAt write fCreatedAt;
    /// <summary>是否批准</summary>
    property Approved: Boolean read fApproved write fApproved;
  end;

  /// <summary>图片模型</summary>
  TImage = class(TOrm)
  private
    fFileName: RawUTF8;
    fFileSize: Integer;
    fMimeType: RawUTF8;
    fStoragePath: RawUTF8;  // 实际存储路径
    fUploadedBy: TID;       // 上传用户ID
    fUploadedAt: TDateTime;
    fWidth: Integer;
    fHeight: Integer;
    fThumbnailPath: RawUTF8; // 缩略图路径
  published
    /// <summary>原始文件名</summary>
    property FileName: RawUTF8 index 255 read fFileName write fFileName;
    /// <summary>文件大小(字节)</summary>
    property FileSize: Integer read fFileSize write fFileSize;
    /// <summary>MIME类型</summary>
    property MimeType: RawUTF8 index 100 read fMimeType write fMimeType;
    /// <summary>存储路径</summary>
    property StoragePath: RawUTF8 index 500 read fStoragePath write fStoragePath;
    /// <summary>上传用户ID</summary>
    property UploadedBy: TID read fUploadedBy write fUploadedBy;
    /// <summary>上传时间</summary>
    property UploadedAt: TDateTime read fUploadedAt write fUploadedAt;
    /// <summary>图片宽度</summary>
    property Width: Integer read fWidth write fWidth;
    /// <summary>图片高度</summary>
    property Height: Integer read fHeight write fHeight;
    /// <summary>缩略图路径</summary>
    property ThumbnailPath: RawUTF8 index 500 read fThumbnailPath write fThumbnailPath;
  end;

  /// <summary>文章-图片关联模型</summary>
  TPostImage = class(TOrm)
  private
    fPostID: TID;
    fImageID: TID;
    fSortOrder: Integer;
  published
    /// <summary>文章ID</summary>
    property PostID: TID read fPostID write fPostID;
    /// <summary>图片ID</summary>
    property ImageID: TID read fImageID write fImageID;
    /// <summary>排序顺序</summary>
    property SortOrder: Integer read fSortOrder write fSortOrder;
  end;

implementation

end.

unit Blog.JWT;

interface

uses
  mormot.core.base,
  mormot.core.data,
  mormot.core.json,
  mormot.core.rtti,
  mormot.crypt.core,
  mormot.crypt.jwt,
  mormot.core.text,
  mormot.core.os,
  Blog.Model;

type
  /// <summary>JWT管理类</summary>
  TBlogJWT = class
  private
    fJWT: TJwtHS256;
    fSecret: RawUTF8;
  public
    /// <summary>使用密钥初始化JWT</summary>
    constructor Create(const aSecret: RawUTF8);
    /// <summary>销毁JWT实例</summary>
    destructor Destroy; override;

    /// <summary>为用户生成JWT令牌</summary>
    function GenerateToken(User: TUser): RawUTF8;
    /// <summary>验证JWT令牌</summary>
    function VerifyToken(const Token: RawUTF8; out UserID: TID;
      out Username: RawUTF8): Boolean;
    /// <summary>验证JWT令牌并返回完整信息</summary>
    function VerifyTokenEx(const Token: RawUTF8; out JwtContent: TJwtContent): Boolean;

    /// <summary>获取JWT实例</summary>
    property JWT: TJwtHS256 read fJWT;
    /// <summary>获取JWT密钥</summary>
    property Secret: RawUTF8 read fSecret;
  end;

implementation

{ TBlogJWT }

constructor TBlogJWT.Create(const aSecret: RawUTF8);
begin
  inherited Create;
  fSecret := aSecret;

  // 创建JWT实例
  // 使用HS256算法,支持所有标准声明
  fJWT := TJwtHS256.Create(
    aSecret,                       // 密钥
    0,                             // PBKDF2轮数(0表示直接使用密钥)
    [jrcIssuer, jrcSubject, jrcAudience, jrcExpirationTime, jrcIssuedAt, jrcJwtID], // 声明
    ['blog-server'],               // 受众
    60,                            // 过期时间(分钟)
    0,                             // ID标识符
    '',                            // ID混淆密钥
    0);                            // 新的KDF轮数

  // 设置选项
  fJWT.Options := [joAllowUnexpectedClaims, joAllowUnexpectedAudience];
  // 允许自定义声明
end;

destructor TBlogJWT.Destroy;
begin
  fJWT.Free;
  inherited;
end;

function TBlogJWT.GenerateToken(User: TUser): RawUTF8;
var
  UserIDStr: RawUTF8;
begin
  // 将用户ID转换为字符串
  UserIDStr := Int64ToUtf8(User.ID);

  // 生成JWT令牌
  Result := fJWT.Compute(
    // 自定义声明
    ['user_id', UserIDStr,
     'username', User.Username,
     'email', User.Email,
     'role', 'user'],  // 默认角色

    // 标准声明
    'blog-server',      // 颁发者
    UserIDStr,         // 主题(用户ID)
    'blog-server',     // 受众
    0,                 // NotBefore
    0);                // ExpirationMinutes(使用默认值)
end;

function TBlogJWT.VerifyToken(const Token: RawUTF8; out UserID: TID;
  out Username: RawUTF8): Boolean;
var
  JwtContent: TJwtContent;
  UserIDStr: RawUTF8;
begin
  Result := False;
  UserID := 0;
  Username := '';

  // 验证令牌
  fJWT.Verify(Token, JwtContent);

  if JwtContent.result = jwtValid then
  begin
    // 从自定义声明中获取用户信息
    if JwtContent.data.GetAsRawUTF8('user_id', UserIDStr) and
       JwtContent.data.GetAsRawUTF8('username', Username) then
    begin
      // 转换用户ID
      UserID := Utf8ToInt64(UserIDStr);
      Result := True;
    end;
  end;
end;

function TBlogJWT.VerifyTokenEx(const Token: RawUTF8; out JwtContent: TJwtContent): Boolean;
begin
  // 验证令牌并返回完整内容
  fJWT.Verify(Token, JwtContent);
  Result := JwtContent.result = jwtValid;
end;

end.

前端我放百度网盘

相关推荐
拔刀能留住落樱吗、3 小时前
代码诊疗室——疑难Bug破解战
前端·html
GISer_Jing3 小时前
Taro 5.0 深度:跨端开发的架构革新与全阶实践指南
前端·react.js·taro
宁雨桥3 小时前
我开源了一个 Chrome 插件:一键总结网页为 Markdown
前端·chrome·开源
南夏一木子3 小时前
Vue学习 —— Axios异步通信
前端·vue.js·学习
GISer_Jing3 小时前
Taro 5.0 小白快速上手指南:从0到1实现跨端开发
前端·react.js·taro
程序员林北北3 小时前
【前端进阶之旅】50 道前端超难面试题(2026 最新版)|覆盖 HTML/CSS/JS/Vue/React/TS/ 工程化 / 网络 / 跨端
前端·javascript·css·vue.js·html
糕冷小美n11 小时前
elementuivue2表格不覆盖整个表格添加固定属性
前端·javascript·elementui
小哥不太逍遥11 小时前
Technical Report 2024
java·服务器·前端
沐墨染12 小时前
黑词分析与可疑对话挖掘组件的设计与实现
前端·elementui·数据挖掘·数据分析·vue·visual studio code