Przeglądaj źródła

feat(news): 添加新闻模块

- 实现了新闻分类和文章的 CRUD 功能
- 添加了新闻模块的数据库迁移脚本
- 编写了新闻模块的配置文件和信息文件
- 实现了新闻模块的 FFIFCGO扩展
runphp 7 miesięcy temu
commit
013eac43a0

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+composer.phar
+/vendor/
+.idea/

+ 351 - 0
README.md

@@ -0,0 +1,351 @@
+# 资讯模块 (News) - FFI 实现
+
+## 模块功能
+
+本模块为 SixShop 系统提供资讯管理功能。其核心业务逻辑(数据库 CRUD)完全由 Go 语言实现,并通过 PHP FFI 进行调用。这种方式可以有效保护核心业务逻辑代码,同时利用 Go 的高性能特性。
+
+## 主要特性
+
+- **后台管理**:
+  - 资讯分类管理 (增删改查)
+  - 资讯文章管理 (增删改查)
+- **API 接口**:
+  - 获取资讯分类列表
+  - 获取指定分类下的文章列表(支持分页)
+  - 获取文章详情
+- **Go FFI 实现**:
+  - 所有数据库操作均在编译好的 Go 共享库 (`.so`) 中执行。
+  - PHP 服务层仅作为调用 FFI 接口的桥梁。
+
+## 目录结构
+
+```
+backend/extension/news/
+├── config/
+│   ├── install.sql       # 数据库表结构
+│   └── uninstall.sql     # 卸载表结构
+├── ffi/
+│   ├── main.go           # Go FFI 业务逻辑和导出函数
+│   ├── go.mod            # Go 依赖管理
+│   ├── Makefile          # 一键构建 Go 共享库的脚本
+│   ├── model/
+│   │   ├── category.go   # 资讯分类 GORM 模型
+│   │   └── news.go       # 资讯文章 GORM 模型
+│   ├── lib_news.so       # [构建产物] Go 编译的共享库
+│   └── lib_news.h        # [构建产物] Go 编译的头文件
+├── route/
+│   ├── admin.php         # 后台路由
+│   └── api.php           # 前端API路由
+├── src/
+│   ├── Controller/
+│   ├── Model/            # ThinkPHP 模型 (仅用于框架集成, 不含业务逻辑)
+│   ├── Service/
+│   │   ├── NewsCategoryService.php # 分类服务的 FFI 调用封装
+│   │   ├── NewsService.php         # 资讯服务的 FFI 调用封装
+│   │   └── NewsFfiService.php      # [核心] FFI 单例服务, 负责加载和调用 .so 文件
+│   └── Hook/
+├── news.info.yml         # 模块信息
+└── README.md             # 模块说明文档
+```
+
+## 数据库安装
+
+- 在插件安装时,需执行 `config/install.sql` 文件,以创建 `cy_news_category` 和 `cy_news` 两张表。
+
+## 核心依赖
+
+- Go 1.20+
+- Docker (用于执行 `Makefile` 中的构建命令)
+- PHP 7.4+ 并已启用 FFI 扩展
+
+## 构建 Go 共享库
+
+所有 Go 源代码的编译都通过 `Makefile` 完成。
+
+```bash
+# 进入 ffi 目录
+cd backend/extension/news/ffi
+
+# 执行构建
+make build
+```
+
+该命令会使用 Docker 容器来编译 Go 代码,确保构建环境的一致性。成功后,会在当前目录下生成 `lib_news.so` 和 `lib_news.h` 两个文件。
+
+## PHP FFI 调用流程
+
+1.  `NewsFfiService` 通过单例模式加载 `lib_news.so` 文件,并根据 `lib_news.h` 的内容定义 C 函数签名。
+2.  `NewsService` 和 `NewsCategoryService` 在实例化时会获取 `NewsFfiService` 的单例。
+3.  当控制器调用 `NewsService` 的方法时(如 `getList`),`NewsService` 会将请求参数(如查询条件、分页信息)打包成 JSON 字符串。
+4.  `NewsService` 调用 `NewsFfiService` 的相应方法,将 JSON 字符串作为参数传递给 Go 函数。
+5.  Go 函数接收参数,执行数据库操作,并将结果打包成 JSON 字符串返回。
+6.  `NewsFfiService` 接收返回的 JSON 字符串,并将其解码为 PHP 数组,最终返回给控制器。
+
+### FFI 接口清单 (Go -> PHP)
+
+所有业务逻辑都已封装在以下 Go 函数中,并通过 FFI 导出:
+
+- `GetCategoryList(char* whereJson)`
+- `GetCategoryByID(int id)`
+- `CreateCategory(char* dataJson)`
+- `UpdateCategory(int id, char* dataJson)`
+- `DeleteCategory(int id)`
+- `GetNewsList(char* paramsJson)`
+- `GetNewsByID(int id)`
+- `CreateNews(char* dataJson)`
+- `UpdateNews(int id, char* dataJson)`
+- `DeleteNews(int id)`
+
+### 注意事项
+
+- **路径依赖**: `NewsFfiService.php` 中硬编码了 `lib_news.so` 的相对路径。如果移动文件,需要同步更新。
+- **重新编译**: 任何对 `ffi/` 目录下 `.go` 文件的修改,都必须重新执行 `make build` 才能生效。
+- **错误处理**: Go FFI 函数中的错误会以包含 `error` 键的 JSON 字符串形式返回,PHP 服务层应进行相应的检查和处理。
+
+## 服务层依赖注入分析
+
+在 `news` 模块的控制器中,存在两种不同的服务层依赖注入(或实例化)方式。这两种方式在设计上存在差异,体现了不同的架构思路。
+
+### 方式一:通过适配器实例化 (Admin 控制器)
+
+此方式应用于 `src/Controller/Admin/` 目录下的所有控制器。
+
+- **实现**: 控制器在其构造函数中,通过 `new` 关键字直接实例化一个**适配器** (`NewsServiceAdapter` 或 `NewsCategoryServiceAdapter`)。
+  ```php
+  // 文件: backend/extension/news/src/Controller/Admin/NewsController.php
+
+  use SixShop\News\Service\NewsServiceAdapter;
+
+  class NewsController
+  {
+      protected $service;
+
+      public function __construct()
+      {
+          // 直接实例化适配器
+          $this->service = new NewsServiceAdapter();
+      }
+      // ...
+  }
+  ```
+- **优点**:
+  - **松耦合**: 控制器本身不关心底层的具体实现。所有的业务逻辑切换(例如,在开发模式下使用纯 PHP 服务,在生产模式下使用 FFI 服务)都封装在适配器内部。
+  - **符合依赖倒置原则**: 控制器依赖于一个稳定的"适配器"抽象,而不是一个多变的具体服务。
+  - **易于维护和扩展**: 如果未来需要增加新的服务实现,只需要修改适配器,控制器代码无需改动。
+- **结论**: 这是项目推荐的、更健壮、更灵活的设计模式。
+
+### 方式二:通过构造函数依赖注入 (Api 控制器)
+
+此方式应用于 `src/Controller/Api/` 目录下的控制器。
+
+- **实现**: 控制器在其构造函数的参数中,声明需要注入的**具体服务** (`NewsService`),由框架的依赖注入容器自动实例化并传入。
+  ```php
+  // 文件: backend/extension/news/src/Controller/Api/NewsController.php
+
+  use SixShop\News\Service\NewsService;
+
+  class NewsController
+  {
+      private $service;
+
+      public function __construct(NewsService $service)
+      {
+          // 由框架注入具体的 NewsService 实例
+          $this->service = $service;
+      }
+      // ...
+  }
+  ```
+- **缺点**:
+  - **紧耦合**: 控制器与具体的 `NewsService` 类紧密绑定。
+  - **缺乏灵活性**: 它无法利用适配器模式带来的好处。如果想为 API 控制器也实现开发/生产模式的切换,就必须重构其构造函数和依赖关系。
+  - **设计不一致**: 与 Admin 控制器的实现方式不统一,增加了项目的维护成本和认知负担。
+- **改进建议**: 为了保持整个模块架构的一致性,建议未来将 Api 控制器的实现方式重构为方式一(使用适配器)。
+
+## API 接口说明
+
+本插件提供两组符合 RESTful 规范的资源路由,分别用于后台管理和前端调用。
+
+### 后台管理 API
+
+路由前缀: `/admin/extension/news`
+
+所有后台接口均会通过 `Auth` 中间件进行权限验证。
+
+#### 1. 资讯分类管理 (`/category`)
+
+-   **获取分类列表**: `GET /category`
+    -   说明: 获取所有资讯分类,支持 `name` 字段模糊查询。
+    -   示例: `GET /admin/extension/news/category?name=技术`
+-   **获取单个分类**: `GET /category/{id}`
+    -   说明: 获取指定 ID 的分类详情。
+-   **新增分类**: `POST /category`
+    -   说明: 创建一个新的资讯分类。
+    -   请求体 (JSON): `{ "name": "新分类", "sort": 100, "status": 1 }`
+-   **更新分类**: `PUT /category/{id}`
+    -   说明: 更新指定 ID 的分类信息。
+    -   请求体 (JSON): `{ "name": "更新后的分类", "sort": 99 }`
+-   **删除分类**: `DELETE /category/{id}`
+    -   说明: 删除指定 ID 的分类。
+
+#### 2. 资讯文章管理 (`/news`)
+
+-   **获取文章列表**: `GET /news`
+    -   说明: 获取所有资讯文章,支持分页,支持按 `title` 和 `category_id` 查询。
+    -   示例: `GET /admin/extension/news/news?title=六店&category_id=1&page=1&limit=10`
+-   **获取单篇文章**: `GET /news/{id}`
+    -   说明: 获取指定 ID 的文章详情。
+-   **新增文章**: `POST /news`
+    -   说明: 创建一篇新的资讯文章。
+    -   请求体 (JSON): `{ "category_id": 1, "title": "文章标题", "content": "文章内容...", "status": 1 }`
+-   **更新文章**: `PUT /news/{id}`
+    -   说明: 更新指定 ID 的文章信息。
+    -   请求体 (JSON): `{ "title": "更新后的标题" }`
+-   **删除文章**: `DELETE /news/{id}`
+    -   说明: 删除指定 ID 的文章。
+
+### 移动端 API
+
+路由前缀: `/api/extension/news`
+
+#### 1. 资讯分类接口 (`/category`)
+
+-   **获取分类列表**: `GET /category`
+    -   说明: 获取所有已启用的资讯分类列表。
+
+#### 2. 资讯文章接口 (`/news`)
+
+-   **获取文章列表**: `GET /news`
+    -   说明: 获取所有已发布的资讯文章,支持按 `category_id` 筛选和分页。
+    -   示例: `GET /api/extension/news/news?category_id=1&page=1&limit=10`
+-   **获取文章详情**: `GET /news/{id}`
+    -   说明: 获取一篇已发布的文章详情。
+
+## 数据库安装与卸载
+
+- **安装表结构**:
+  - 执行 `config/install.sql`,自动创建资讯分类表(cy_news_category)和资讯文章表(cy_news)。
+- **卸载表结构**:
+  - 执行 `config/uninstall.sql`,自动删除本模块相关表结构。
+
+> 推荐在模块安装、升级、卸载时自动调用对应 SQL,便于独立管理和维护。 
+
+## FFI 动态库能力说明
+
+本模块内置 Go FFI 动态库(`ffi/` 目录),用于高性能、可闭源的资讯业务处理,PHP 可通过 FFI 调用。
+
+### 依赖安装
+
+- 需 Go 1.20+
+- 依赖 GORM、yaml.v2
+- 安装依赖:
+
+```bash
+cd ffi
+go mod tidy
+```
+
+### 数据库配置
+
+- 配置文件:`config/db.yaml`
+- 支持 MySQL,参数与 PHP 项目一致
+
+### 构建动态库
+
+- 推荐用 Makefile 一键构建:
+
+```bash
+cd ffi
+make build
+# 生成 lib_news.so 和 lib_news.h
+```
+
+- 也可用 Dockerfile 构建
+
+### 主要接口
+
+- `GetList()`         获取资讯列表(JSON)
+- `GetById(id)`       获取单条资讯详情(JSON)
+- `Create(json)`      新增资讯,参数为 JSON
+- `Update(id, json)`  编辑资讯
+- `Delete(id)`        删除资讯
+- `GenerateSummary(content)` 生成摘要
+- `CheckContent(content)`    内容审核
+- `ExtractKeywords(content)` 关键词提取
+
+### PHP FFI 调用示例
+
+```php
+$ffi = FFI::cdef(file_get_contents('lib_news.h'), 'lib_news.so');
+$list = json_decode(FFI::string($ffi->GetList()), true);
+$info = json_decode(FFI::string($ffi->GetById(1)), true);
+$id = $ffi->Create(json_encode($data));
+$ok = $ffi->Update($id, json_encode($data));
+$ok = $ffi->Delete($id);
+$summary = FFI::string($ffi->GenerateSummary($content));
+$isIllegal = $ffi->CheckContent($content);
+$keywords = explode(',', FFI::string($ffi->ExtractKeywords($content)));
+```
+
+### 注意事项
+- 动态库需与 PHP FFI 路径一致
+- 数据库表结构需提前安装(见 config/install.sql)
+- 推荐配合 PHP Service 层切换调用 
+
+## 插件编译与分发
+
+### 一键编译与分发
+
+- 推荐使用 shell 脚本(需 bash 环境):
+  ```bash
+  cd backend/extension/news
+  ./build.sh
+  ```
+- 或使用 PHP 脚本(适合 PHP-only 环境):
+  ```bash
+  cd backend/extension/news
+  php build.php
+  ```
+
+#### 编译/分发脚本高级特性
+- **增量编译**:自动检测 ffi/ 下所有 Go 源码变更,无变更则跳过 make build,极大提升效率。
+- **编译日志**:所有编译和打包输出写入 `build/build.log`,便于排查问题。
+- **自动打包**:编译完成后自动将 `build/` 目录内容打包为 `build/package.zip`,方便分发和备份。
+- **产物一致**:无论用 shell 还是 PHP 脚本,体验和产物完全一致。
+
+### 编译产物
+
+- `build/lib_news.so`、`build/lib_news.h`:Go FFI 动态库及头文件
+- `build/NewsServiceFFI.php`:PHP FFI 适配层,控制器/接口直接调用
+- `build/package.zip`:自动打包的分发包
+- `build/build.log`:编译和打包日志
+
+### 开发与分发模式
+
+- **开发模式**:保留全部源码,便于调试和测试
+- **分发模式**:只保留 build 目录和必要接口,核心业务完全闭源
+
+### 控制器调用 FFI 适配层示例
+
+```php
+require_once __DIR__ . '/../build/NewsServiceFFI.php';
+$service = new NewsService();
+$list = $service->getList();
+``` 
+
+## 服务适配层(ServiceAdapter)设计说明
+
+为实现开发模式(PHP Service)与分发模式(FFI 适配层)的无缝切换,所有控制器均依赖适配层(如 NewsServiceAdapter、NewsCategoryServiceAdapter),而不直接依赖具体 Service 实现。
+
+- **开发模式**:适配层自动加载并调用 PHP Service,便于本地开发和调试。
+- **分发模式**:适配层自动加载并调用 FFI 适配层(build/NewsServiceFFI.php),核心逻辑闭源,安全分发。
+- 控制器层代码无需变动,适配层自动切换底层实现。
+
+### 控制器用法示例
+
+```php
+$service = new NewsServiceAdapter();
+$list = $service->getList();
+```
+
+适配层代码位于 `src/Service/NewsServiceAdapter.php`、`src/Service/NewsCategoryServiceAdapter.php`,可根据实际业务扩展更多方法。 

+ 83 - 0
build.sh

@@ -0,0 +1,83 @@
+#!/bin/bash
+set -e
+
+# --- News FFI 模块编译脚本 ---
+# 本脚本仅用于在本地开发环境中编译 Go 源代码为 .so 共享库。
+
+echo "[BUILD] 进入 ffi 目录..."
+cd ffi
+
+echo "[BUILD] 开始编译 Go 源码..."
+# 运行 make 命令,它会处理清理和编译的全部工作
+make
+
+echo "[BUILD] 编译成功! lib_news.so 已更新。"
+echo ""
+echo "==================== 重要提醒 ===================="
+echo "下一步,你必须手动重启 PHP-FPM 服务来清除缓存,"
+echo "否则你的代码改动将不会生效!"
+echo ""
+echo "请运行以下命令:"
+echo "docker-compose restart php"
+echo "================================================"
+
+# 返回原始目录
+cd ..
+
+# 2. 生成 PHP FFI 适配层
+cat > $BUILD_DIR/NewsServiceFFI.php <<EOF
+<?php
+class NewsService
+{
+    protected $ffi;
+    public function __construct()
+    {
+        $this->ffi = FFI::cdef(
+            file_get_contents(__DIR__ . '/lib_news.h'),
+            __DIR__ . '/lib_news.so'
+        );
+    }
+    public function getList()
+    {
+        return json_decode(FFI::string($this->ffi->GetList()), true);
+    }
+    public function getById($id)
+    {
+        return json_decode(FFI::string($this->ffi->GetById($id)), true);
+    }
+    public function create($data)
+    {
+        return $this->ffi->Create(json_encode($data));
+    }
+    public function update($id, $data)
+    {
+        return $this->ffi->Update($id, json_encode($data));
+    }
+    public function delete($id)
+    {
+        return $this->ffi->Delete($id);
+    }
+    public function generateSummary($content)
+    {
+        return FFI::string($this->ffi->GenerateSummary($content));
+    }
+    public function checkContent($content)
+    {
+        return $this->ffi->CheckContent($content);
+    }
+    public function extractKeywords($content)
+    {
+        return explode(',', FFI::string($this->ffi->ExtractKeywords($content)));
+    }
+}
+EOF
+
+echo "[build] PHP FFI 适配层已生成。" | tee -a $LOG_FILE
+
+# 3. 自动打包 build 目录
+cd $BUILD_DIR
+zip -r package.zip ./* > /dev/null
+cd ..
+echo "[build] 已自动打包为 $PACKAGE" | tee -a $LOG_FILE
+
+echo "[build] FFI 动态库和 PHP 适配层已生成到 build/ 目录。详见 $LOG_FILE" 

+ 33 - 0
composer.json

@@ -0,0 +1,33 @@
+{
+  "name": "six-shop/news",
+  "description": "资讯,文章,新闻,公告发布",
+  "type": "sixshop-extension",
+  "keywords": [
+    "sixshop",
+    "thinkphp"
+  ],
+  "require": {
+    "php": ">=8.3",
+    "six-shop/core": ">=0.5.7 <1.0"
+  },
+  "authors": [
+    {
+      "name": "hui he",
+      "email": "runphp@qq.com"
+    }
+  ],
+  "license": "MIT",
+  "autoload": {
+    "psr-4": {
+      "SixShop\\News\\": "src"
+    }
+  },
+  "extra": {
+    "sixshop": {
+      "id": "message",
+      "class": "SixShop\\News"
+    }
+  },
+  "minimum-stability": "dev",
+  "prefer-stable": true
+}

+ 144 - 0
config.php

@@ -0,0 +1,144 @@
+<?php
+declare(strict_types=1);
+
+return [
+    [
+        'type' => 'switch',
+        'field' => 'is_cache',
+        'title' => '缓存开关',
+        'info' => '是否开启新闻数据缓存',
+        '$required' => false,
+        'props' => [
+            'activeValue' => true,
+            'inactiveValue' => false
+        ],
+        '_fc_id' => 'id_news_cache',
+        'name' => 'ref_news_cache',
+        'display' => true,
+        'hidden' => false,
+        '_fc_drag_tag' => 'switch'
+    ],
+    [
+        'type' => 'input-number',
+        'field' => 'list_page_size',
+        'title' => '列表页大小',
+        'info' => '新闻列表每页显示的数量',
+        '$required' => true,
+        'props' => [
+            'min' => 5,
+            'max' => 50,
+            'defaultValue' => 20
+        ],
+        '_fc_id' => 'id_news_page_size',
+        'name' => 'ref_news_page_size',
+        'display' => true,
+        'hidden' => false,
+        '_fc_drag_tag' => 'input-number'
+    ],
+    [
+        'type' => 'select',
+        'field' => 'default_order',
+        'title' => '默认排序',
+        'info' => '新闻列表默认排序方式',
+        '$required' => true,
+        'props' => [
+            'options' => [
+                [
+                    'label' => '创建时间降序',
+                    'value' => 'create_time_desc'
+                ],
+                [
+                    'label' => '创建时间升序',
+                    'value' => 'create_time_asc'
+                ],
+                [
+                    'label' => '浏览量降序',
+                    'value' => 'views_desc'
+                ],
+                [
+                    'label' => '浏览量升序',
+                    'value' => 'views_asc'
+                ]
+            ],
+            'defaultValue' => 'create_time_desc'
+        ],
+        '_fc_id' => 'id_news_order',
+        'name' => 'ref_news_order',
+        'display' => true,
+        'hidden' => false,
+        '_fc_drag_tag' => 'select'
+    ],
+    [
+        'type' => 'switch',
+        'field' => 'enable_comment',
+        'title' => '开启评论',
+        'info' => '是否允许用户对新闻进行评论',
+        '$required' => false,
+        'props' => [
+            'activeValue' => true,
+            'inactiveValue' => false
+        ],
+        '_fc_id' => 'id_news_comment',
+        'name' => 'ref_news_comment',
+        'display' => true,
+        'hidden' => false,
+        '_fc_drag_tag' => 'switch'
+    ],
+    [
+        'type' => 'group',
+        'field' => 'category_settings',
+        'title' => '分类设置',
+        'info' => '新闻分类相关设置',
+        '$required' => false,
+        'props' => [
+            'rule' => [
+                [
+                    'type' => 'fcRow',
+                    'children' => [
+                        [
+                            'type' => 'col',
+                            'props' => [
+                                'span' => 12
+                            ],
+                            'children' => [
+                                [
+                                    'type' => 'switch',
+                                    'field' => 'enable_multi_level',
+                                    'title' => '多级分类',
+                                    'info' => '是否启用多级分类结构',
+                                    '$required' => false,
+                                    'props' => [
+                                        'activeValue' => true,
+                                        'inactiveValue' => false
+                                    ],
+                                    '_fc_id' => 'id_news_multi_level',
+                                    'name' => 'ref_news_multi_level',
+                                    'display' => true,
+                                    'hidden' => false,
+                                    '_fc_drag_tag' => 'switch'
+                                ],
+                                [
+                                    'type' => 'input-number',
+                                    'field' => 'max_level',
+                                    'title' => '最大层级',
+                                    'info' => '分类最大层级数',
+                                    '$required' => false,
+                                    'props' => [
+                                        'min' => 1,
+                                        'max' => 5,
+                                        'defaultValue' => 3
+                                    ],
+                                    '_fc_id' => 'id_news_max_level',
+                                    'name' => 'ref_news_max_level',
+                                    'display' => true,
+                                    'hidden' => false,
+                                    '_fc_drag_tag' => 'input-number'
+                                ]
+                            ]
+                        ]
+                    ]
+                ]
+            ]
+        ]
+    ]
+];

+ 11 - 0
config/db.yaml

@@ -0,0 +1,11 @@
+# 数据库配置
+
+database:
+  host: "127.0.0.1"
+  port: 3310
+  user: "root"
+  password: "nWGnQXMUx8u34mD7jaYc"
+  dbname: "sixshop"
+  charset: "utf8mb4"
+  parseTime: true
+  loc: Local 

+ 37 - 0
config/install.sql

@@ -0,0 +1,37 @@
+-- ----------------------------
+-- Table structure for cy_news_category
+-- ----------------------------
+DROP TABLE IF EXISTS `cy_news_category`;
+CREATE TABLE `cy_news_category` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `parent_id` int(11) NOT NULL DEFAULT '0' COMMENT '父分类ID',
+  `name` varchar(50) NOT NULL COMMENT '分类名称',
+  `sort` int(11) NOT NULL DEFAULT '100' COMMENT '排序,值越小越靠前',
+  `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0禁用,1正常',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `delete_time` datetime DEFAULT NULL COMMENT '删除时间',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资讯分类表';
+
+-- ----------------------------
+-- Table structure for cy_news
+-- ----------------------------
+DROP TABLE IF EXISTS `cy_news`;
+CREATE TABLE `cy_news` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `category_id` int(11) NOT NULL COMMENT '分类ID',
+  `title` varchar(255) NOT NULL COMMENT '文章标题',
+  `author` varchar(50) DEFAULT NULL COMMENT '作者',
+  `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图',
+  `summary` varchar(255) DEFAULT NULL COMMENT '摘要',
+  `content` longtext NOT NULL COMMENT '文章内容',
+  `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0草稿,1发布',
+  `is_recommend` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否推荐',
+  `is_top` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否置顶',
+  `views` int(11) NOT NULL DEFAULT '0' COMMENT '浏览量',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `delete_time` datetime DEFAULT NULL COMMENT '删除时间',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资讯文章表'; 

+ 5 - 0
config/uninstall.sql

@@ -0,0 +1,5 @@
+-- 删除资讯文章表
+DROP TABLE IF EXISTS `cy_news`;
+
+-- 删除资讯分类表
+DROP TABLE IF EXISTS `cy_news_category`; 

+ 2 - 0
ffi/.gitignore

@@ -0,0 +1,2 @@
+.idea/
+*.so

+ 22 - 0
ffi/Makefile

@@ -0,0 +1,22 @@
+# Makefile
+
+BINARY=lib_news.so
+GO_VERSION=1.24
+
+LOCAL_GOPATH := $(shell go env GOPATH)
+
+GO_BUILD=docker run --rm \
+    -v $(PWD):/app \
+    -v $(LOCAL_GOPATH):/go \
+    -w /app \
+    -e GOPROXY=https://goproxy.cn,direct \
+    golang:$(GO_VERSION) \
+    go build -o $(BINARY) -buildmode=c-shared
+
+all: clean build
+
+build:
+	$(GO_BUILD) main.go
+
+clean:
+	rm -f $(BINARY) $(BINARY:.so=.h)

+ 57 - 0
ffi/README.md

@@ -0,0 +1,57 @@
+# News FFI 模块开发指南
+
+本目录包含 `news` 插件的核心数据逻辑,该逻辑通过 Go 语言实现并通过 FFI (Foreign Function Interface) 提供给 PHP 使用。
+
+## 模块架构
+
+- `main.go`: Go 语言实现的 CRUD (增删改查) 服务,处理所有新闻和新闻分类的数据操作。
+- `lib_news.so`: 由 `main.go` 编译成的共享库文件,PHP FFI 实际加载和调用的对象。
+- `Makefile`: 自动化编译脚本。
+
+## 数据库配置
+
+本模块的数据库连接信息**不是**硬编码在 Go 代码中的。它通过一个可导出的 `Initialize` 函数,在运行时由 PHP 动态注入。PHP 端 (`NewsFfiService.php`) 会从项目的 `.env` 文件中读取配置,并在第一次使用本模块时进行初始化。
+
+## 重要:开发与调试工作流
+
+由于 PHP-FPM 会在内存中缓存加载的 `.so` 库文件,因此每次修改 `main.go` 文件后,必须严格遵循以下步骤才能使改动生效:
+
+1.  **修改 Go 代码**:
+    在 `main.go` 文件中进行你的修改。
+
+2.  **重新编译 `.so` 文件**:
+    在当前目录 (`backend/extension/news/ffi/`) 下,运行 `make` 命令。
+    ```bash
+    make
+    ```
+    这将调用 Docker 来编译 Go 代码,并生成最新的 `lib_news.so` 文件。
+
+3.  **重启 PHP-FPM 服务**:
+    **这是最关键且最容易忘记的一步**。必须重启 PHP-FPM 容器来清除旧的 FFI 缓存,并强制它加载新的 `.so` 文件。
+    ```bash
+    docker-compose restart php
+    ```
+
+**⚠️ 警告:** 如果你编译后没有重启 PHP-FPM,你的任何 Go 代码改动都**不会**生效,并且可能会遇到 `Failed resolving C function` 之类的错误。
+
+## 架构决策考量 (Architectural Considerations)
+
+将核心数据逻辑用 Go 实现并通过 FFI 暴露给 PHP,是基于代码安全和技术战略的综合考量。
+
+### 核心优势:代码保护
+
+这是采用 FFI 方案最主要的目的。
+
+- **编译型 vs. 解释型**: PHP 作为一种解释型语言,其源代码 (`.php` 文件) 在部署时是完全可见的。而 Go 是一种编译型语言,我们最终部署的是由源代码编译成的二进制共享库 (`.so` 文件),无需提供 `.go` 源文件。
+- **逆向工程难度**: 从二进制的 `.so` 文件反向工程出可读的 Go 源代码是极其困难的,需要专业的工具和深厚的底层知识。这为项目中最核心的业务逻辑提供了非常高级别的知识产权(IP)保护。
+- **结论**: 此架构为核心代码资产上了一把非常坚固的"锁",防止商业逻辑被轻易复制或泄露。
+
+### 性能考量
+
+对于当前模块执行的数据库增删改查(CRUD)这类 **I/O 密集型 (I/O-Bound)** 任务,性能并非首要考量,其特点如下:
+
+- **瓶颈在于I/O**: 此类任务的绝大部分时间都消耗在等待数据库返回结果的网络延迟和磁盘读写上,而不是 CPU 计算。因此,无论是用 PHP 还是 Go 来执行,最终的性能瓶颈都在于 MySQL 服务器。
+- **FFI 调用开销**: PHP 与 Go 之间的函数调用会产生一定的开销(例如数据类型的转换)。对于我们这种单次调用、内部执行重逻辑的场景,这点开销几乎可以忽略不计。
+- **结论**: 在当前场景下,使用 Go FFI 的性能与纯 PHP 实现**基本持平**,没有显著的提升或下降。选择此方案并非为了追求性能,而是为了实现上述的代码保护目标。
+
+> **注**: 如果未来有 **计算密集型 (CPU-Bound)** 的任务(如复杂的算法、图像处理等),将其用 Go 实现则会带来**数量级**的性能提升。 

+ 17 - 0
ffi/go.mod

@@ -0,0 +1,17 @@
+module newsffi
+
+go 1.20
+
+require (
+	gopkg.in/yaml.v2 v2.4.0
+	gorm.io/driver/mysql v1.6.0
+	gorm.io/gorm v1.30.0
+)
+
+require (
+	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/go-sql-driver/mysql v1.8.1 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
+	golang.org/x/text v0.20.0 // indirect
+)

+ 21 - 0
ffi/go.sum

@@ -0,0 +1,21 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
+gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
+gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
+gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
+gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
+gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

+ 91 - 0
ffi/lib_news.h

@@ -0,0 +1,91 @@
+/* Code generated by cmd/cgo; DO NOT EDIT. */
+
+/* package command-line-arguments */
+
+
+#line 1 "cgo-builtin-export-prolog"
+
+#include <stddef.h>
+
+#ifndef GO_CGO_EXPORT_PROLOGUE_H
+#define GO_CGO_EXPORT_PROLOGUE_H
+
+#ifndef GO_CGO_GOSTRING_TYPEDEF
+typedef struct { const char *p; ptrdiff_t n; } _GoString_;
+#endif
+
+#endif
+
+/* Start of preamble from import "C" comments.  */
+
+
+
+
+/* End of preamble from import "C" comments.  */
+
+
+/* Start of boilerplate cgo prologue.  */
+#line 1 "cgo-gcc-export-header-prolog"
+
+#ifndef GO_CGO_PROLOGUE_H
+#define GO_CGO_PROLOGUE_H
+
+typedef signed char GoInt8;
+typedef unsigned char GoUint8;
+typedef short GoInt16;
+typedef unsigned short GoUint16;
+typedef int GoInt32;
+typedef unsigned int GoUint32;
+typedef long long GoInt64;
+typedef unsigned long long GoUint64;
+typedef GoInt64 GoInt;
+typedef GoUint64 GoUint;
+typedef size_t GoUintptr;
+typedef float GoFloat32;
+typedef double GoFloat64;
+#ifdef _MSC_VER
+#include <complex.h>
+typedef _Fcomplex GoComplex64;
+typedef _Dcomplex GoComplex128;
+#else
+typedef float _Complex GoComplex64;
+typedef double _Complex GoComplex128;
+#endif
+
+/*
+  static assertion to make sure the file is being used on architecture
+  at least with matching size of GoInt.
+*/
+typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
+
+#ifndef GO_CGO_GOSTRING_TYPEDEF
+typedef _GoString_ GoString;
+#endif
+typedef void *GoMap;
+typedef void *GoChan;
+typedef struct { void *t; void *v; } GoInterface;
+typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
+
+#endif
+
+/* End of boilerplate cgo prologue.  */
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+extern char* Initialize(char* host, char* user, char* password, char* dbname, char* charset, int port);
+extern char* GetCategoryList(char* whereJson);
+extern char* GetCategoryByID(int id);
+extern char* CreateCategory(char* dataJson);
+extern char* UpdateCategory(int id, char* dataJson);
+extern char* DeleteCategory(int id);
+extern char* GetNewsList(char* paramsJson);
+extern char* GetNewsByID(int id);
+extern char* CreateNews(char* dataJson);
+extern char* UpdateNews(int id, char* dataJson);
+extern char* DeleteNews(int id);
+
+#ifdef __cplusplus
+}
+#endif

+ 389 - 0
ffi/main.go

@@ -0,0 +1,389 @@
+package main
+
+import (
+	"C"
+	"encoding/json"
+	"fmt"
+	"newsffi/model"
+
+	// "time" // This is not used directly in main.go
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// --- Helper Structs for JSON RPC ---
+type NewsListParams struct {
+	Where map[string]interface{} `json:"where"`
+	Order string                 `json:"order"`
+	Page  int                    `json:"page"`
+	Limit int                    `json:"limit"`
+}
+
+type NewsListResult struct {
+	Total int64        `json:"total"`
+	Data  []model.News `json:"data"`
+}
+
+// Config struct to map the yaml structure
+type Config struct {
+	Database DatabaseConfig `yaml:"database"`
+}
+
+type DatabaseConfig struct {
+	Host     string `yaml:"host"`
+	Port     int    `yaml:"port"`
+	User     string `yaml:"user"`
+	Password string `yaml:"password"`
+	DbName   string `yaml:"dbname"`
+	Charset  string `yaml:"charset"`
+}
+
+// Global database connection pool
+var db *gorm.DB
+
+// --- Initialization Function ---
+
+//export Initialize
+func Initialize(host, user, password, dbname, charset *C.char, port C.int) *C.char {
+	// Prevent re-initialization
+	if db != nil {
+		return C.CString(`{"status": "ok", "message": "already initialized"}`)
+	}
+
+	goHost := C.GoString(host)
+	goUser := C.GoString(user)
+	goPassword := C.GoString(password)
+	goDbname := C.GoString(dbname)
+	goCharset := C.GoString(charset)
+	goPort := int(port)
+
+	var err error
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
+		goUser,
+		goPassword,
+		goHost,
+		goPort,
+		goDbname,
+		goCharset,
+	)
+
+	gormDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
+		Logger: logger.Default.LogMode(logger.Silent), // Use Silent to avoid excessive logging
+	})
+
+	if err != nil {
+		db = nil // Ensure db is nil on failure
+		return C.CString(fmt.Sprintf(`{"error": "failed to connect database: %v"}`, err))
+	}
+
+	db = gormDB
+
+	// Auto-migrate schema on first successful initialization
+	err = db.AutoMigrate(&model.NewsCategory{}, &model.News{})
+	if err != nil {
+		db = nil // Rollback on failure
+		return C.CString(fmt.Sprintf(`{"error": "database migration failed: %v"}`, err))
+	}
+
+	return C.CString(`{"status": "ok"}`)
+}
+
+// --- Category Service Functions ---
+
+//export GetCategoryList
+func GetCategoryList(whereJson *C.char) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var where map[string]interface{}
+	goWhereJson := C.GoString(whereJson)
+	if goWhereJson != "" && goWhereJson != "{}" {
+		if err := json.Unmarshal([]byte(goWhereJson), &where); err != nil {
+			return C.CString(fmt.Sprintf(`{"error": "invalid where json: %s"}`, err.Error()))
+		}
+	}
+
+	var categories []model.NewsCategory
+	query := db.Model(&model.NewsCategory{})
+
+	if len(where) > 0 {
+		query = query.Where(where)
+	}
+
+	if err := query.Order("sort asc, id desc").Find(&categories).Error; err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "query failed: %s"}`, err.Error()))
+	}
+
+	resultJson, err := json.Marshal(categories)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export GetCategoryByID
+func GetCategoryByID(id C.int) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var category model.NewsCategory
+	if err := db.First(&category, int(id)).Error; err != nil {
+		if err == gorm.ErrRecordNotFound {
+			return C.CString(fmt.Sprintf(`{"error": "category with id %d not found"}`, id))
+		}
+		return C.CString(fmt.Sprintf(`{"error": "query failed: %s"}`, err.Error()))
+	}
+
+	resultJson, err := json.Marshal(category)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export CreateCategory
+func CreateCategory(dataJson *C.char) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var category model.NewsCategory
+	goDataJson := C.GoString(dataJson)
+	if err := json.Unmarshal([]byte(goDataJson), &category); err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "invalid data json: %s"}`, err.Error()))
+	}
+
+	category.ID = 0
+
+	if err := db.Create(&category).Error; err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "create failed: %s"}`, err.Error()))
+	}
+
+	resultJson, err := json.Marshal(category)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export UpdateCategory
+func UpdateCategory(id C.int, dataJson *C.char) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var category model.NewsCategory
+	if err := db.First(&category, int(id)).Error; err != nil {
+		if err == gorm.ErrRecordNotFound {
+			return C.CString(fmt.Sprintf(`{"error": "category with id %d not found"}`, id))
+		}
+		return C.CString(fmt.Sprintf(`{"error": "query failed: %s"}`, err.Error()))
+	}
+
+	var updateData map[string]interface{}
+	goDataJson := C.GoString(dataJson)
+	if err := json.Unmarshal([]byte(goDataJson), &updateData); err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "invalid data json: %s"}`, err.Error()))
+	}
+
+	delete(updateData, "id")
+	delete(updateData, "ID")
+
+	if err := db.Model(&category).Updates(updateData).Error; err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "update failed: %s"}`, err.Error()))
+	}
+
+	resultJson, err := json.Marshal(category)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export DeleteCategory
+func DeleteCategory(id C.int) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	result := db.Delete(&model.NewsCategory{}, int(id))
+	if result.Error != nil {
+		return C.CString(fmt.Sprintf(`{"error": "delete failed: %s"}`, result.Error.Error()))
+	}
+
+	if result.RowsAffected == 0 {
+		return C.CString(fmt.Sprintf(`{"error": "category with id %d not found or already deleted"}`, id))
+	}
+
+	return C.CString(`{"status": "ok"}`)
+}
+
+// --- News Service Functions ---
+
+//export GetNewsList
+func GetNewsList(paramsJson *C.char) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var params NewsListParams
+	goParamsJson := C.GoString(paramsJson)
+	if goParamsJson != "" && goParamsJson != "{}" {
+		if err := json.Unmarshal([]byte(goParamsJson), &params); err != nil {
+			return C.CString(fmt.Sprintf(`{"error": "invalid params json: %s"}`, err.Error()))
+		}
+	}
+
+	if params.Page <= 0 {
+		params.Page = 1
+	}
+	if params.Limit <= 0 {
+		params.Limit = 20
+	}
+	if params.Order == "" {
+		params.Order = "is_top desc, id desc"
+	}
+
+	var news []model.News
+	var total int64
+
+	query := db.Model(&model.News{})
+
+	if len(params.Where) > 0 {
+		query = query.Where(params.Where)
+	}
+
+	if err := query.Count(&total).Error; err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "count query failed: %s"}`, err.Error()))
+	}
+
+	offset := (params.Page - 1) * params.Limit
+
+	if err := query.Order(params.Order).Offset(offset).Limit(params.Limit).Find(&news).Error; err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "query failed: %s"}`, err.Error()))
+	}
+
+	result := NewsListResult{
+		Total: total,
+		Data:  news,
+	}
+
+	resultJson, err := json.Marshal(result)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export GetNewsByID
+func GetNewsByID(id C.int) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var news model.News
+	if err := db.First(&news, int(id)).Error; err != nil {
+		if err == gorm.ErrRecordNotFound {
+			return C.CString(fmt.Sprintf(`{"error": "news with id %d not found"}`, id))
+		}
+		return C.CString(fmt.Sprintf(`{"error": "query failed: %s"}`, err.Error()))
+	}
+
+	resultJson, err := json.Marshal(news)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export CreateNews
+func CreateNews(dataJson *C.char) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var news model.News
+	goDataJson := C.GoString(dataJson)
+	if err := json.Unmarshal([]byte(goDataJson), &news); err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "invalid data json: %s"}`, err.Error()))
+	}
+
+	news.ID = 0
+
+	if err := db.Create(&news).Error; err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "create failed: %s"}`, err.Error()))
+	}
+
+	resultJson, err := json.Marshal(news)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export UpdateNews
+func UpdateNews(id C.int, dataJson *C.char) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	var news model.News
+	if err := db.First(&news, int(id)).Error; err != nil {
+		if err == gorm.ErrRecordNotFound {
+			return C.CString(fmt.Sprintf(`{"error": "news with id %d not found"}`, id))
+		}
+		return C.CString(fmt.Sprintf(`{"error": "query failed: %s"}`, err.Error()))
+	}
+
+	var updateData map[string]interface{}
+	goDataJson := C.GoString(dataJson)
+	if err := json.Unmarshal([]byte(goDataJson), &updateData); err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "invalid data json: %s"}`, err.Error()))
+	}
+
+	delete(updateData, "id")
+	delete(updateData, "ID")
+
+	if err := db.Model(&news).Updates(updateData).Error; err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "update failed: %s"}`, err.Error()))
+	}
+
+	resultJson, err := json.Marshal(news)
+	if err != nil {
+		return C.CString(fmt.Sprintf(`{"error": "json marshal failed: %s"}`, err.Error()))
+	}
+
+	return C.CString(string(resultJson))
+}
+
+//export DeleteNews
+func DeleteNews(id C.int) *C.char {
+	if db == nil {
+		return C.CString(`{"error": "database connection is not initialized"}`)
+	}
+
+	result := db.Delete(&model.News{}, int(id))
+	if result.Error != nil {
+		return C.CString(fmt.Sprintf(`{"error": "delete failed: %s"}`, result.Error.Error()))
+	}
+
+	if result.RowsAffected == 0 {
+		return C.CString(fmt.Sprintf(`{"error": "news with id %d not found or already deleted"}`, id))
+	}
+
+	return C.CString(`{"status": "ok"}`)
+}
+
+// main is required for building a C shared library.
+func main() {}

+ 28 - 0
ffi/model/article.go

@@ -0,0 +1,28 @@
+package model
+
+type Article struct {
+	ID          int     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
+	CategoryID  int     `gorm:"column:category_id" json:"category_id"`
+	Title       string  `gorm:"column:title" json:"title"`
+	Cover       string  `gorm:"column:cover" json:"cover"`
+	Summary     string  `gorm:"column:summary" json:"summary"`
+	Content     string  `gorm:"column:content" json:"content"`
+	Author      string  `gorm:"column:author" json:"author"`
+	IsTop       int     `gorm:"column:is_top" json:"is_top"`
+	Status      int     `gorm:"column:status" json:"status"`
+	Sort        int     `gorm:"column:sort" json:"sort"`
+	ViewCount   int     `gorm:"column:view_count" json:"view_count"`
+	PublishTime string  `gorm:"column:publish_time" json:"publish_time"`
+	CreateTime  string  `gorm:"column:create_time" json:"create_time"`
+	UpdateTime  string  `gorm:"column:update_time" json:"update_time"`
+	DeleteTime  *string `gorm:"column:delete_time" json:"delete_time"`
+}
+
+func (Article) TableName() string {
+	return "cy_news"
+}
+
+var Articles = []Article{
+	{ID: 1, CategoryID: 1, Title: "Go FFI 资讯", Content: "Go FFI 是 PHP 高性能扩展方案...", Status: 1, ViewCount: 10},
+	{ID: 2, CategoryID: 2, Title: "PHP FFI 实战", Content: "PHP 7.4+ 支持 FFI...", Status: 1, ViewCount: 5},
+}

+ 23 - 0
ffi/model/category.go

@@ -0,0 +1,23 @@
+package model
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// NewsCategory 对应 cy_news_category 表
+type NewsCategory struct {
+	ID         uint           `gorm:"primaryKey"`
+	ParentID   uint           `gorm:"column:parent_id;default:0;not null"`
+	Name       string         `gorm:"column:name;size:50;not null"`
+	Sort       int            `gorm:"column:sort;default:100;not null"`
+	Status     int8           `gorm:"column:status;default:1;not null"`
+	CreateTime time.Time      `gorm:"column:create_time"`
+	UpdateTime time.Time      `gorm:"column:update_time"`
+	DeleteTime gorm.DeletedAt `gorm:"column:delete_time;index"`
+}
+
+func (NewsCategory) TableName() string {
+	return "cy_news_category"
+}

+ 29 - 0
ffi/model/news.go

@@ -0,0 +1,29 @@
+package model
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// News 对应 cy_news 表
+type News struct {
+	ID          uint           `gorm:"primaryKey" json:"id"`
+	CategoryID  uint           `gorm:"column:category_id;not null" json:"category_id"`
+	Title       string         `gorm:"column:title;size:255;not null" json:"title"`
+	Author      string         `gorm:"column:author;size:50" json:"author,omitempty"`
+	CoverImage  string         `gorm:"column:cover_image;size:255" json:"cover_image,omitempty"`
+	Summary     string         `gorm:"column:summary;size:255" json:"summary,omitempty"`
+	Content     string         `gorm:"column:content;type:longtext;not null" json:"content"`
+	Status      int8           `gorm:"column:status;default:1;not null" json:"status"`
+	IsRecommend int8           `gorm:"column:is_recommend;default:0;not null" json:"is_recommend"`
+	IsTop       int8           `gorm:"column:is_top;default:0;not null" json:"is_top"`
+	Views       int            `gorm:"column:views;default:0;not null" json:"views"`
+	CreateTime  time.Time      `gorm:"column:create_time" json:"create_time"`
+	UpdateTime  time.Time      `gorm:"column:update_time" json:"update_time"`
+	DeleteTime  gorm.DeletedAt `gorm:"column:delete_time;index" json:"delete_time,omitempty"`
+}
+
+func (News) TableName() string {
+	return "cy_news"
+}

+ 24 - 0
info.php

@@ -0,0 +1,24 @@
+<?php
+declare(strict_types=1);
+
+return [
+    'id' => 'news',
+    'name' => '新闻模块',
+    'category' => 'content', # 扩展的分类 core:核心扩展,content:内容扩展,shop:商城扩展,other:其他扩展
+    'description' => '提供文章、公告等资讯内容的管理与展示功能,支持多级分类、置顶推荐、评论等特性。',
+    'version' => '1.0.0',
+    'core_version' => '^1.0',
+    'author' => 'runphp', # 作者
+    'email' => 'runphp@qq.com', # 作者的邮箱
+    'website' => 'https://github.com/runphp/sixshop-news', # 扩展的地址,可以是扩展的仓库地址,帮助用户寻找扩展,安装扩展等网络地址
+    'image' => '', # 扩展的图片,用于展示扩展的图标,或者是扩展的截图等图片地址
+    'license' => 'MIT', # 扩展的开源协议
+    'keywords' => ['新闻', '文章', '资讯', '公告'], # 扩展的关键词,用于搜索
+    'dependencies' => [], # 依赖的其他扩展
+    'conflicts' => [], # 与哪些扩展冲突
+    'requires' => [
+        'php' => '>=8.0.0',
+        'extensions' => ['json', 'pdo'],
+    ], # 运行环境要求
+    'is_core' => false, # 是否核心扩展
+];

+ 36 - 0
route/admin.php

@@ -0,0 +1,36 @@
+<?php
+declare(strict_types=1);
+
+use SixShop\Core\Middleware\AuthMiddleware;
+use SixShop\News\Controller\Admin\CategoryController;
+use SixShop\News\Controller\Admin\NewsController;
+use SixShop\News\Controller\Admin\UploadController;
+use think\facade\Route;
+
+// 以下是在后台管理路由
+// 路由前缀: /admin/news
+
+// 在 news 插件下所有路由都设置在同一个 Group中
+Route::group('news', function () {
+    // 文章管理路由
+    Route::get('', [NewsController::class, 'index'])->middleware(['auth']);
+    Route::get(':id', [NewsController::class, 'read'])->middleware(['auth']);
+    Route::post('', [NewsController::class, 'create'])->middleware(['auth']);
+    Route::post(':id', [NewsController::class, 'edit'])->middleware(['auth']);
+    Route::put(':id', [NewsController::class, 'update'])->middleware(['auth']);
+    Route::delete(':id', [NewsController::class, 'delete'])->middleware(['auth']);
+
+})->middleware(['auth']);
+
+  // 上传接口
+Route::post('upload', [UploadController::class, 'image'])->middleware(['auth']);
+
+Route::group('category', function () { 
+    // 分类管理路由 - 在 news 下
+    Route::get('', [CategoryController::class, 'index'])->middleware(['auth']);
+    Route::get(':id', [CategoryController::class, 'read'])->middleware(['auth']);
+    Route::post('', [CategoryController::class, 'create'])->middleware(['auth']);
+    Route::put(':id', [CategoryController::class, 'update'])->middleware(['auth']);
+    Route::delete(':id', [CategoryController::class, 'delete'])->middleware(['auth']);
+
+})->middleware(['auth']);

+ 14 - 0
route/api.php

@@ -0,0 +1,14 @@
+<?php
+declare(strict_types=1);
+
+use SixShop\News\Controller\Api\CategoryController;
+use SixShop\News\Controller\Api\NewsController;
+use think\facade\Route;
+
+// 移动端/H5等API路由
+// 路由前缀: /admin/news
+
+// 分类API资源路由
+Route::resource('category', CategoryController::class);
+// 文章API资源路由
+Route::resource('news', NewsController::class);

+ 88 - 0
src/Controller/Admin/CategoryController.php

@@ -0,0 +1,88 @@
+<?php
+namespace SixShop\News\Controller\Admin;
+
+use think\Request;
+use think\facade\Validate;
+use SixShop\News\Service\NewsCategoryServiceAdapter;
+
+class CategoryController
+{
+    protected $service;
+
+    public function __construct()
+    {
+        $this->service = new NewsCategoryServiceAdapter();
+    }
+
+    /**
+     * 分类列表
+     */
+    public function index(Request $request)
+    {
+        $data = $this->service->getList();
+        return json(['code' => 200, 'msg' => 'success', 'data' => $data]);
+    }
+
+    /**
+     * 分类详情
+     */
+    public function read($id)
+    {
+        $info = $this->service->getById($id);
+        if (!$info) {
+            return json(['code' => 404, 'msg' => '分类不存在']);
+        }
+        return json(['code' => 200, 'msg' => 'success', 'data' => $info]);
+    }
+
+    /**
+     * 新增分类
+     */
+    public function create(Request $request)
+    {
+        $data = $request->only(['name', 'sort', 'status']);
+        $validate = Validate::rule([
+            'name' => 'require|max:64',
+            'sort' => 'number',
+            'status' => 'in:0,1',
+        ]);
+        if (!$validate->check($data)) {
+            return json(['code' => 422, 'msg' => $validate->getError()]);
+        }
+        $res = $this->service->create($data);
+        return json(['code' => 200, 'msg' => '创建成功', 'data' => $res]);
+    }
+
+    /**
+     * 编辑分类
+     */
+    public function update($id, Request $request)
+    {
+        $data = $request->only(['name', 'sort', 'status']);
+        $validate = Validate::rule([
+            'name' => 'require|max:64',
+            'sort' => 'number',
+            'status' => 'in:0,1',
+        ]);
+        if (!$validate->check($data)) {
+            return json(['code' => 422, 'msg' => $validate->getError()]);
+        }
+        $res = $this->service->update($id, $data);
+        if (!$res) {
+            return json(['code' => 404, 'msg' => '分类不存在']);
+        }
+        return json(['code' => 200, 'msg' => '更新成功', 'data' => $res]);
+    }
+
+    /**
+     * 删除分类
+     */
+    public function delete($id)
+    {
+        $res = $this->service->delete($id);
+        if (!$res) {
+            return json(['code' => 404, 'msg' => '分类不存在']);
+        }
+        return json(['code' => 200, 'msg' => '删除成功']);
+    }
+} 

+ 190 - 0
src/Controller/Admin/NewsController.php

@@ -0,0 +1,190 @@
+<?php
+namespace SixShop\News\Controller\Admin;
+
+use think\Request;
+use think\facade\Validate;
+use SixShop\News\Service\NewsServiceAdapter;
+use SixShop\News\Service\NewsCategoryServiceAdapter;
+
+class NewsController
+{
+    protected $service;
+
+    public function __construct()
+    {
+        $this->service = new NewsServiceAdapter();
+    }
+
+    /**
+     * 文章列表
+     */
+    public function index(Request $request)
+    {
+        $where = [];
+        if ($request->get('category_id')) {
+            $where['category_id'] = $request->get('category_id');
+        }
+        if ($request->get('status') !== null) {
+            $where['status'] = $request->get('status');
+        }
+        $limit = (int)$request->get('limit', 20);
+        $data = $this->service->getList($where, ['is_top'=>'desc','id'=>'desc'], $limit);
+        
+        // 从数据库获取分类数据
+        $categoryMap = [];
+        try {
+            // 使用ThinkPHP的Db类查询数据库
+            $categories = \think\facade\Db::table('cy_news_category')
+                ->where('status', 1)
+                ->whereNull('delete_time')
+                ->field('id,name')
+                ->select()
+                ->toArray();
+            
+            // 构建分类映射
+            foreach ($categories as $category) {
+                $categoryMap[$category['id']] = $category['name'];
+            }
+        } catch (\Exception $e) {
+            // 如果查询失败,使用默认映射
+            error_log('获取分类数据失败: ' . $e->getMessage());
+            $categoryMap = [
+                1 => '默认分类',
+                2 => '新闻分类', 
+                3 => '公告分类'
+            ];
+        }
+        
+        // 字段映射:数据库cover_image -> 前端cover,添加分类名称
+        if (is_array($data) && isset($data['data'])) {
+            foreach ($data['data'] as &$item) {
+                if (isset($item['cover_image'])) {
+                    $item['cover'] = $item['cover_image'];
+                }
+                if (isset($item['category_id']) && isset($categoryMap[$item['category_id']])) {
+                    $item['category_name'] = $categoryMap[$item['category_id']];
+                }
+            }
+        } elseif (is_array($data) && isset($data['list'])) {
+            foreach ($data['list'] as &$item) {
+                if (isset($item['cover_image'])) {
+                    $item['cover'] = $item['cover_image'];
+                }
+                if (isset($item['category_id']) && isset($categoryMap[$item['category_id']])) {
+                    $item['category_name'] = $categoryMap[$item['category_id']];
+                }
+            }
+        } elseif (is_array($data)) {
+            foreach ($data as &$item) {
+                if (isset($item['cover_image'])) {
+                    $item['cover'] = $item['cover_image'];
+                }
+                if (isset($item['category_id']) && isset($categoryMap[$item['category_id']])) {
+                    $item['category_name'] = $categoryMap[$item['category_id']];
+                }
+            }
+        }
+        
+        return json(['code' => 200, 'msg' => 'success', 'data' => $data]);
+    }
+
+    /**
+     * 文章详情
+     */
+    public function read($id)
+    {
+        $info = $this->service->getById($id);
+        if (!$info) {
+            return json(['code' => 404, 'msg' => '文章不存在']);
+        }
+        
+        // 字段映射:数据库cover_image -> 前端cover
+        if (isset($info['cover_image'])) {
+            $info['cover'] = $info['cover_image'];
+        }
+        
+        return json(['code' => 200, 'msg' => 'success', 'data' => $info]);
+    }
+
+    /**
+     * 新增文章
+     */
+    public function create(Request $request)
+    {
+        $data = $request->only(['category_id','title','cover','summary','content','author','is_top','status']);
+        $validate = Validate::rule([
+            'category_id' => 'require|number',
+            'title' => 'require|max:128',
+            'cover' => 'max:255',
+            'summary' => 'max:255',
+            'content' => 'require',
+            'author' => 'max:64',
+            'is_top' => 'in:0,1',
+            'status' => 'in:0,1,2',
+        ]);
+        if (!$validate->check($data)) {
+            return json(['code' => 422, 'msg' => $validate->getError()]);
+        }
+        
+        // 字段映射:前端cover -> 数据库cover_image
+        if (isset($data['cover'])) {
+            $data['cover_image'] = $data['cover'];
+            unset($data['cover']);
+        }
+        
+        // 添加创建时间和更新时间(临时注释测试)
+    // $currentTime = date('Y-m-d H:i:s');
+    // $data['create_time'] = $currentTime;
+    // $data['update_time'] = $currentTime;
+        
+        $res = $this->service->create($data);
+        return json(['code' => 200, 'msg' => '创建成功', 'data' => $res]);
+    }
+
+    /**
+     * 编辑文章
+     */
+    public function update($id, Request $request)
+    {
+        $data = $request->only(['category_id','title','cover','summary','content','author','is_top','status']);
+        $validate = Validate::rule([
+            'category_id' => 'number',
+            'title' => 'max:128',
+            'cover' => 'max:255',
+            'summary' => 'max:255',
+            'author' => 'max:64',
+            'is_top' => 'in:0,1',
+            'status' => 'in:0,1,2',
+        ]);
+        if (!$validate->check($data)) {
+            return json(['code' => 422, 'msg' => $validate->getError()]);
+        }
+        
+        // 字段映射:前端cover -> 数据库cover_image
+        if (isset($data['cover'])) {
+            $data['cover_image'] = $data['cover'];
+            unset($data['cover']);
+        }
+        
+        // 添加更新时间(临时注释测试)
+        // $data['update_time'] = date('Y-m-d H:i:s');
+        
+        $res = $this->service->update($id, $data);
+        if (!$res) {
+            return json(['code' => 404, 'msg' => '文章不存在']);
+        }
+        return json(['code' => 200, 'msg' => '更新成功', 'data' => $res]);
+    }
+
+    /**
+     * 删除文章
+     */
+    public function delete($id)
+    {
+        $res = $this->service->delete($id);
+        if (!$res) {
+            return json(['code' => 404, 'msg' => '文章不存在']);
+        }
+        return json(['code' => 200, 'msg' => '删除成功']);
+    }
+} 

+ 78 - 0
src/Controller/Api/CategoryController.php

@@ -0,0 +1,78 @@
+<?php
+namespace SixShop\News\Controller\Api;
+
+use think\Response;
+use think\Request;
+use SixShop\News\Service\NewsCategoryService;
+
+class CategoryController
+{
+    private $service;
+
+    public function __construct(NewsCategoryService $service)
+    {
+        $this->service = $service;
+    }
+
+    /**
+     * Display a listing of the resource.
+     *
+     * @return Response
+     */
+    public function index(): Response
+    {
+        $list = $this->service->getList();
+        return json($list);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return Response
+     */
+    public function read($id): Response
+    {
+        $item = $this->service->getById($id);
+        return json($item);
+    }
+    
+    /**
+     * Save a new resource in storage.
+     *
+     * @param  Request  $request
+     * @return Response
+     */
+    public function save(Request $request): Response
+    {
+        $data = $request->post();
+        $result = $this->service->create($data);
+        return json($result);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  Request  $request
+     * @param  int  $id
+     * @return Response
+     */
+    public function update(Request $request, $id): Response
+    {
+        $data = $request->put();
+        $result = $this->service->update($id, $data);
+        return json($result);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return Response
+     */
+    public function delete($id): Response
+    {
+        $result = $this->service->delete($id);
+        return json($result);
+    }
+} 

+ 85 - 0
src/Controller/Api/NewsController.php

@@ -0,0 +1,85 @@
+<?php
+namespace SixShop\News\Controller\Api;
+
+use think\Response;
+use think\Request;
+use SixShop\News\Service\NewsService;
+
+class NewsController
+{
+    private $service;
+
+    public function __construct(NewsService $service)
+    {
+        $this->service = $service;
+    }
+
+    /**
+     * Display a listing of the resource.
+     *
+     * @param  Request  $request
+     * @return Response
+     */
+    public function index(Request $request): Response
+    {
+        $params = $request->get();
+        $list = $this->service->getList(
+            $params['where'] ?? [],
+            $params['order'] ?? ['is_top' => 'desc', 'id' => 'desc'],
+            $params['limit'] ?? 20,
+            $params['page'] ?? 1
+        );
+        return json($list);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return Response
+     */
+    public function read($id): Response
+    {
+        $item = $this->service->getById($id);
+        return json($item);
+    }
+    
+    /**
+     * Save a new resource in storage.
+     *
+     * @param  Request  $request
+     * @return Response
+     */
+    public function save(Request $request): Response
+    {
+        $data = $request->post();
+        $result = $this->service->create($data);
+        return json($result);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  Request  $request
+     * @param  int  $id
+     * @return Response
+     */
+    public function update(Request $request, $id): Response
+    {
+        $data = $request->put();
+        $result = $this->service->update($id, $data);
+        return json($result);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return Response
+     */
+    public function delete($id): Response
+    {
+        $result = $this->service->delete($id);
+        return json($result);
+    }
+} 

+ 69 - 0
src/Extension.php

@@ -0,0 +1,69 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\News;
+
+use SixShop\Core\ExtensionAbstract;
+use SixShop\News\Hook\NewsHook;
+use think\facade\Db;
+
+class Extension extends ExtensionAbstract
+{
+    /**
+     * 安装扩展
+     * 执行数据库迁移,创建必要的表结构
+     */
+    public function install(): void
+    {
+        // 执行安装SQL脚本
+        $sqlFile = $this->getBaseDir() . '/config/install.sql';
+        if (file_exists($sqlFile)) {
+            $sql = file_get_contents($sqlFile);
+            $statements = array_filter(array_map('trim', explode(';', $sql)));
+
+            foreach ($statements as $statement) {
+                if (!empty($statement)) {
+                    Db::execute($statement);
+                }
+            }
+        }
+
+        // 可以在这里添加其他安装逻辑
+        // 例如:创建默认配置、初始化数据等
+    }
+
+    protected function getBaseDir(): string
+    {
+        return dirname(__DIR__);
+    }
+
+    /**
+     * 卸载扩展
+     * 清理数据库表和其他资源
+     */
+    public function uninstall(): void
+    {
+        // 执行卸载SQL脚本
+        $sqlFile = $this->getBaseDir() . '/config/uninstall.sql';
+        if (file_exists($sqlFile)) {
+            $sql = file_get_contents($sqlFile);
+            $statements = array_filter(array_map('trim', explode(';', $sql)));
+
+            foreach ($statements as $statement) {
+                if (!empty($statement)) {
+                    Db::execute($statement);
+                }
+            }
+        }
+
+        // 可以在这里添加其他卸载逻辑
+        // 例如:清理缓存、删除上传的文件等
+    }
+
+    public function getHooks(): array
+    {
+        return [
+            NewsHook::class,
+        ];
+    }
+}

+ 31 - 0
src/Hook/NewsHook.php

@@ -0,0 +1,31 @@
+<?php
+namespace SixShop\News\Hook;
+
+use SixShop\Core\Attribute\Hook;
+
+/**
+ * 资讯模块钩子处理类
+ */
+class NewsHook
+{
+    /**
+     * 示例钩子:在用户登录后触发
+     * @param $params
+     */
+    // #[Hook('user_login')]
+    public function onUserLogin($params)
+    {
+        // 在这里编写用户登录后的逻辑
+        // 例如:记录资讯相关的用户活动
+    }
+
+    /**
+     * 示例钩子:在订单创建后触发
+     * @param $params
+     */
+    // #[Hook('after_order_create')]
+    public function afterOrderCreate($params)
+    {
+        // 在这里编写订单创建后的逻辑
+    }
+} 

+ 25 - 0
src/Model/News.php

@@ -0,0 +1,25 @@
+<?php
+namespace SixShop\News\Model;
+
+use think\Model;
+use think\model\concern\SoftDelete;
+
+class News extends Model
+{
+    use SoftDelete;
+
+    protected $name = 'news';
+    protected $pk = 'id';
+    protected $autoWriteTimestamp = 'datetime';
+    protected $deleteTime = 'delete_time'; // 软删除时间戳字段
+
+    /**
+     * 定义与新闻分类表的关联关系
+     *
+     * @return \think\model\relation\BelongsTo
+     */
+    public function category()
+    {
+        return $this->belongsTo(NewsCategory::class, 'category_id', 'id');
+    }
+}

+ 36 - 0
src/Model/NewsCategory.php

@@ -0,0 +1,36 @@
+<?php
+namespace SixShop\News\Model;
+
+use think\Model;
+use think\model\concern\SoftDelete;
+
+class NewsCategory extends Model
+{
+    use SoftDelete;
+
+    protected $name = 'news_category';
+    protected $pk = 'id';
+    protected $autoWriteTimestamp = 'datetime';
+    protected $deleteTime = 'delete_time'; // 软删除时间戳字段
+
+    /**
+     * 定义与新闻表的关联关系
+     *
+     * @return \think\model\relation\HasMany
+     */
+    public function news()
+    {
+        return $this->hasMany(News::class, 'category_id', 'id');
+    }
+
+    /**
+     * 字段注释:
+     * - parent_id: 父级分类ID,0表示顶级分类
+     * - name: 分类名称
+     * - slug: 分类别名
+     * - description: 分类描述
+     * - icon: 分类图标
+     * - sort_order: 排序
+     * - status: 状态:1启用,-1禁用
+     */
+}

+ 58 - 0
src/Service/NewsCategoryService.php

@@ -0,0 +1,58 @@
+<?php
+namespace SixShop\News\Service;
+
+// The original Model is no longer used directly by this service.
+// use SixShop\News\Model\NewsCategory;
+
+class NewsCategoryService
+{
+    private NewsFfiService $ffiService;
+
+    public function __construct()
+    {
+        // Get the singleton instance of our FFI wrapper
+        $this->ffiService = NewsFfiService::getInstance();
+    }
+
+    /**
+     * 获取分类列表
+     */
+    public function getList($where = [], $order = ['sort' => 'asc', 'id' => 'desc'])
+    {
+        // The 'order' parameter is now handled inside the Go layer by default.
+        // For more complex ordering, the Go service would need to be updated to parse it.
+        return $this->ffiService->getCategoryList($where);
+    }
+
+    /**
+     * 获取单个分类详情
+     */
+    public function getById($id)
+    {
+        return $this->ffiService->getCategoryById($id);
+    }
+
+    /**
+     * 新增分类
+     */
+    public function create($data)
+    {
+        return $this->ffiService->createCategory($data);
+    }
+
+    /**
+     * 编辑分类
+     */
+    public function update($id, $data)
+    {
+        return $this->ffiService->updateCategory($id, $data);
+    }
+
+    /**
+     * 删除分类(软删除)
+     */
+    public function delete($id)
+    {
+        return $this->ffiService->deleteCategory($id);
+    }
+} 

+ 25 - 0
src/Service/NewsCategoryServiceAdapter.php

@@ -0,0 +1,25 @@
+<?php
+namespace SixShop\News\Service;
+
+// 资讯分类服务适配器:开发模式用 PHP Service,分发模式用 FFI 适配层(通过 NewsServiceAdapter 代理实现)
+class NewsCategoryServiceAdapter
+{
+    protected $service;
+    public function __construct()
+    {
+        if (class_exists('NewsCategoryService', false) || file_exists(__DIR__ . '/NewsCategoryService.php')) {
+            // 开发模式:用 PHP Service
+            require_once __DIR__ . '/NewsCategoryService.php';
+            $this->service = new NewsCategoryService();
+        } else {
+            // 分发模式:用 FFI 适配层(通过 NewsServiceAdapter 代理实现分类相关方法)
+            require_once __DIR__ . '/NewsServiceAdapter.php';
+            $this->service = new NewsServiceAdapter();
+        }
+    }
+    public function getList(...$args) { return $this->service->getList(...$args); }
+    public function getById(...$args) { return $this->service->getById(...$args); }
+    public function create(...$args) { return $this->service->create(...$args); }
+    public function update(...$args) { return $this->service->update(...$args); }
+    public function delete(...$args) { return $this->service->delete(...$args); }
+} 

+ 169 - 0
src/Service/NewsFfiService.php

@@ -0,0 +1,169 @@
+<?php
+namespace SixShop\News\Service;
+
+use FFI;
+use Exception;
+
+/**
+ * FFI Service Wrapper for the News extension's Go library.
+ * Implements a singleton pattern to ensure the library is loaded only once.
+ */
+class NewsFfiService
+{
+    private static ?self $instance = null;
+    private ?FFI $ffi = null;
+
+    /**
+     * Private constructor to prevent direct instantiation.
+     * Loads the shared library and defines the C function signatures.
+     */
+    private function __construct()
+    {
+        try {
+            $cdef = "
+                // Initialization function
+                char* Initialize(char* host, char* user, char* password, char* dbname, char* charset, int port);
+
+                // Category functions
+                char* GetCategoryList(char* whereJson);
+                char* GetCategoryByID(int id);
+                char* CreateCategory(char* dataJson);
+                char* UpdateCategory(int id, char* dataJson);
+                char* DeleteCategory(int id);
+
+                // News functions
+                char* GetNewsList(char* paramsJson);
+                char* GetNewsByID(int id);
+                char* CreateNews(char* dataJson);
+                char* UpdateNews(int id, char* dataJson);
+                char* DeleteNews(int id);
+            ";
+
+            $libPath = __DIR__ . '/../../ffi/lib_news.so';
+            if (!file_exists($libPath)) {
+                throw new Exception("FFI library not found at: $libPath. Please run 'make' in the ffi directory.");
+            }
+
+            $this->ffi = FFI::cdef($cdef, $libPath);
+
+            // Dynamically initialize the Go module with config from the environment
+            $dbConfig = config('database.connections.mysql');
+            
+            $resultJson = FFI::string($this->ffi->Initialize(
+                $dbConfig['hostname'],
+                $dbConfig['username'],
+                $dbConfig['password'],
+                $dbConfig['database'],
+                $dbConfig['charset'],
+                $dbConfig['hostport']
+            ));
+
+            $result = json_decode($resultJson, true);
+            if (!isset($result['status']) || $result['status'] !== 'ok') {
+                $error = $result['error'] ?? 'unknown initialization error';
+                throw new Exception("Failed to initialize Go FFI database: " . $error);
+            }
+
+        } catch (Exception $e) {
+            // Handle FFI loading errors gracefully
+            // In a real application, you'd log this error.
+            // For now, we can re-throw it to make debugging easier.
+            throw new Exception("Failed to initialize News FFI Service: " . $e->getMessage());
+        }
+    }
+
+    /**
+     * Gets the single instance of the FFI service.
+     */
+    public static function getInstance(): self
+    {
+        if (self::$instance === null) {
+            self::$instance = new self();
+        }
+        return self::$instance;
+    }
+
+    /**
+     * Generic private method to handle FFI calls, reducing code duplication.
+     * It marshals arguments to C-compatible types and unmarshals the JSON result.
+     */
+    private function call(string $method, ...$args)
+    {
+        if ($this->ffi === null) {
+            throw new Exception("FFI service is not available.");
+        }
+
+        // The Go functions return a char* which needs to be converted to a PHP string.
+        $resultPtr = $this->ffi->$method(...$args);
+        $resultJson = FFI::string($resultPtr);
+
+        // In Go, we allocated the string using C.CString, which uses malloc.
+        // However, the responsibility for freeing memory with FFI can be tricky.
+        // Go's FFI implementation for returning `*C.char` from a Go string often
+        // places it in memory that Go's GC manages. Explicitly freeing it can
+        // cause a double-free panic. For this implementation, we assume Go's GC handles it.
+        // C.free(FFI::addr($resultPtr)); // Do NOT do this unless you are sure.
+
+        return json_decode($resultJson, true);
+    }
+
+    // --- Category Service Wrappers ---
+
+    public function getCategoryList(array $where = []): array
+    {
+        $payload = empty($where) ? new \stdClass() : $where;
+        return $this->call('GetCategoryList', json_encode($payload));
+    }
+
+    public function getCategoryById(int $id): array
+    {
+        return $this->call('GetCategoryByID', $id);
+    }
+
+    public function createCategory(array $data): array
+    {
+        $payload = empty($data) ? new \stdClass() : $data;
+        return $this->call('CreateCategory', json_encode($payload));
+    }
+
+    public function updateCategory(int $id, array $data): array
+    {
+        $payload = empty($data) ? new \stdClass() : $data;
+        return $this->call('UpdateCategory', $id, json_encode($payload));
+    }
+
+    public function deleteCategory(int $id): array
+    {
+        return $this->call('DeleteCategory', $id);
+    }
+
+    // --- News Service Wrappers ---
+
+    public function getNewsList(array $params = []): array
+    {
+        $payload = empty($params) ? new \stdClass() : $params;
+        return $this->call('GetNewsList', json_encode($payload));
+    }
+
+    public function getNewsById(int $id): array
+    {
+        return $this->call('GetNewsByID', $id);
+    }
+
+    public function createNews(array $data): array
+    {
+        $payload = empty($data) ? new \stdClass() : $data;
+        return $this->call('CreateNews', json_encode($payload));
+    }
+
+    public function updateNews(int $id, array $data): array
+    {
+        $payload = empty($data) ? new \stdClass() : $data;
+        return $this->call('UpdateNews', $id, json_encode($payload));
+    }
+
+    public function deleteNews(int $id): array
+    {
+        return $this->call('DeleteNews', $id);
+    }
+} 

+ 66 - 0
src/Service/NewsService.php

@@ -0,0 +1,66 @@
+<?php
+namespace SixShop\News\Service;
+
+// The original Model is no longer used directly by this service.
+// use SixShop\News\Model\News;
+
+class NewsService
+{
+    private NewsFfiService $ffiService;
+
+    public function __construct()
+    {
+        // Get the singleton instance of our FFI wrapper
+        $this->ffiService = NewsFfiService::getInstance();
+    }
+
+    /**
+     * 获取文章列表
+     */
+    public function getList($where = [], $order = ['is_top' => 'desc', 'id' => 'desc'], $limit = 20, $page = 1)
+    {
+        // 强制将分页参数转为整型,避免通过 query 传入的字符串在 JSON 编码后导致 Go 端反序列化失败
+        $limit = (int) $limit;
+        $page  = (int) $page;
+
+        $params = [
+            'where' => empty($where) ? new \stdClass() : $where,
+            'order' => is_array($order) ? implode(', ', array_map(fn($k, $v) => "$k $v", array_keys($order), $order)) : $order,
+            'limit' => $limit,
+            'page'  => $page,
+        ];
+        return $this->ffiService->getNewsList($params);
+    }
+
+    /**
+     * 获取单个文章详情
+     */
+    public function getById($id)
+    {
+        return $this->ffiService->getNewsById($id);
+    }
+
+    /**
+     * 新增文章
+     */
+    public function create($data)
+    {
+        return $this->ffiService->createNews($data);
+    }
+
+    /**
+     * 编辑文章
+     */
+    public function update($id, $data)
+    {
+        return $this->ffiService->updateNews($id, $data);
+    }
+
+    /**
+     * 删除文章(软删除)
+     */
+    public function delete($id)
+    {
+        return $this->ffiService->deleteNews($id);
+    }
+} 

+ 27 - 0
src/Service/NewsServiceAdapter.php

@@ -0,0 +1,27 @@
+<?php
+namespace SixShop\News\Service;
+// 资讯服务适配器:开发模式用 PHP Service,分发模式用 FFI 适配层
+class NewsServiceAdapter
+{
+    protected $service;
+    public function __construct()
+    {
+        if (class_exists('NewsService', false) || file_exists(__DIR__ . '/NewsService.php')) {
+            // 开发模式:用 PHP Service
+            require_once __DIR__ . '/NewsService.php';
+            $this->service = new NewsService();
+        } else {
+            // 分发模式:用 FFI 适配层
+            require_once __DIR__ . '/NewsServiceFFI.php';
+            $this->service = new NewsService();
+        }
+    }
+    public function getList(...$args) { return $this->service->getList(...$args); }
+    public function getById(...$args) { return $this->service->getById(...$args); }
+    public function create(...$args) { return $this->service->create(...$args); }
+    public function update(...$args) { return $this->service->update(...$args); }
+    public function delete(...$args) { return $this->service->delete(...$args); }
+    public function generateSummary(...$args) { return $this->service->generateSummary(...$args); }
+    public function checkContent(...$args) { return $this->service->checkContent(...$args); }
+    public function extractKeywords(...$args) { return $this->service->extractKeywords(...$args); }
+}