# CmsPro 应用开发文档

> 版本：1.6 | 更新日期：2026-06-18
> 适用系统：CmsPro v5.0.0+

***

## 一、概述

CmsPro 应用系统允许开发者创建独立的功能插件，通过标准化的安装/卸载流程集成到 CmsPro 系统中。每个应用在隔离目录中运行，支持完全无污染的安装与卸载。

### 核心特性

- **目录隔离**：应用文件全部位于 `app/Apps/{AppName}/` 目录
- **命名空间隔离**：每个应用使用 `App\Apps\{AppName}\` 命名空间
- **数据库隔离**：应用表使用 `app_{app_id}_` 前缀
- **视图隔离**：应用视图使用命名空间前缀访问
- **配置隔离**：应用配置通过 `apps.{app_id}` 键访问
- **钩子系统**：支持 Action 和 Filter 两种钩子类型
- **版本管理**：支持语义化版本号和增量升级

***

## 二、快速开始

### 创建应用目录

```
code/app/Apps/Blog/
├── manifest.json
├── icon.svg               # 应用图标（推荐 SVG 格式）
├── ServiceProvider.php
├── Hooks.php              # 可选
├── Install.php            # 可选
├── Routes/
│   └── web.php
├── Controllers/
│   └── PostController.php
├── Models/
│   └── Post.php
├── Services/
│   └── PostService.php
├── Migrations/
│   └── 2026_05_22_000001_create_app_blog_posts_table.php
├── Views/
│   └── index.blade.php
├── Config/
│   └── blog.php
└── Assets/
    ├── css/
    └── js/
```

### 最小化应用

一个最简单的应用只需要两个文件：

**manifest.json**

```json
{
    "id": "helloworld.hello",
    "name": "你好世界",
    "description": "示例应用",
    "version": "1.0.0",
    "author": "Developer",
    "require": {
        "php": ">=8.3",
        "cmspro": ">=5.0.0"
    },
    "providers": ["ServiceProvider"]
}
```

**ServiceProvider.php**

```php
<?php

namespace App\Apps\Hello;

use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider as BaseServiceProvider;

class ServiceProvider extends BaseServiceProvider
{
    public function boot(): void
    {
        $this->registerRoutes();
        $this->loadViews();
    }

    protected function registerRoutes(): void
    {
        Route::prefix('admin/hello')
            ->namespace('App\Apps\Hello\Controllers')
            ->middleware(['auth:admin'])
            ->group(base_path('app/Apps/Hello/Routes/web.php'));
    }

    protected function loadViews(): void
    {
        $this->loadViewsFrom(
            base_path('app/Apps/Hello/Views'),
            'hello'
        );
    }
}
```

***

## 三、manifest.json 规范

`manifest.json` 是应用的唯一标识和声明文件，必须放在应用根目录。

### 完整字段

```json
{
    "id": "cmspro.blog",
    "name": "博客",
    "description": "博客应用，支持文章发布、分类、标签管理",
    "version": "1.0.0",
    "author": "CmsPro",
    "author_url": "https://example.com",
    "icon": "fa fa-book",
    "require": {
        "php": ">=8.1",
        "cmspro": ">=5.0.0"
    },
    "dependencies": {
        "app_ids": [],
        "versions": {}
    },
    "providers": ["ServiceProvider"],
    "hooks": "Hooks",
    "install": "Install",
    "menus": [],
    "config_groups": [],
    "permissions": []
}
```

### 字段说明

| 字段             | 类型     | 必填 | 说明                                           |
| -------------- | ------ | -- | -------------------------------------------- |
| id             | string | 是  | 应用唯一标识，**必须**采用 `开发者唯一标识.应用标识` 格式（如 `cmspro.blog`），详见「应用命名规范」章节 |
| name           | string | 是  | 应用显示名称                                       |
| description    | string | 是  | 应用描述                                         |
| version        | string | 是  | 语义化版本号（如 1.0.0）                              |
| author         | string | 是  | 作者                                           |
| author\_url    | string | 否  | 作者主页                                         |
| icon           | string | 否  | 应用图标，支持三种格式：FontAwesome 类名（如 `fa fa-book`）、Layui 图标类名（如 `layui-icon layui-icon-app`）、图片 URL。优先使用应用目录下的 `icon.svg` 或 `icon.png` 文件展示 |
| require        | object | 是  | 系统要求，支持 `php` 和 `cmspro` 两个键                 |
| dependencies   | object | 否  | 依赖的其他应用，`app_ids` 为依赖应用ID数组，`versions` 为版本约束。依赖应用必须已安装**且已启用**，否则安装将被拒绝 |
| providers      | array  | 是  | ServiceProvider 类名数组                         |
| hooks          | string | 否  | Hooks 类名                                     |
| install        | string | 否  | Install 类名（默认为 `Install`）                    |
| menus          | array  | 否  | 声明的菜单结构                                      |
| config\_groups | array  | 否  | 声明的配置组（系统自动添加应用前缀，详见第十二章）                    |
| permissions    | array  | 否  | 声明的权限列表，支持对象格式（推荐）或字符串格式（兼容）                 |
| home\_routes   | object | 否  | 前台路由占用声明，键为路由路径，值为路由中文名称。用于安装/启用时检测路由冲突       |

### 版本约束格式

```
">=8.3"      大于等于
">5.0.0"     大于
"<=2.0.0"    小于等于
"<2.0.0"     小于
"1.0.0"      精确匹配（等同于 >=1.0.0）
```

### 应用图标

应用图标在管理后台的应用列表中展示，支持以下方式（按优先级从高到低）：

1. **图标文件**（推荐）：在应用根目录放置 `icon.svg` 或 `icon.png` 文件，系统通过 `/api/app/{appId}/icon` 接口自动提供图标访问。SVG 优先于 PNG。
2. **FontAwesome 类名**：在 `manifest.json` 的 `icon` 字段中使用 FontAwesome 图标类名，如 `"fa fa-book"`。
3. **Layui 图标类名**：使用 Layui 内置图标类名，如 `"layui-icon layui-icon-app"`。
4. **图片 URL**：使用远程图片地址，如 `"https://example.com/icon.png"`。

**图标文件规范：**

| 项目 | 说明 |
|------|------|
| 文件名 | `icon.svg`（推荐）或 `icon.png` |
| 位置 | 应用根目录，与 `manifest.json` 同级 |
| 尺寸 | 建议 120×120 像素 |
| 格式 | SVG 优先（矢量、体积小），PNG 作为备选 |
| 访问路径 | `/api/app/{appId}/icon`（无需认证） |

**目录结构示例：**

```
code/app/Apps/Blog/
├── manifest.json
├── icon.svg          ← 应用图标文件
├── ServiceProvider.php
└── ...
```

**manifest.json 配合使用：**

```json
{
    "id": "cmspro.blog",
    "name": "博客",
    "icon": "fa fa-book",
    ...
}
```

> 当图标文件（`icon.svg`/`icon.png`）存在时，系统优先使用图标文件展示；图标文件不存在时，回退到 `icon` 字段指定的 FontAwesome/Layui 类名显示。

### 应用依赖

应用可通过 `dependencies` 字段声明对其他应用的依赖关系。安装时系统会校验依赖应用是否满足条件：

**校验规则：**
- 依赖应用必须**已安装**，否则返回错误码 `50004`
- 依赖应用必须**已启用**（状态为 `ENABLED`），否则返回错误码 `50017`
- 存在依赖关系的应用被卸载时，系统会阻止卸载并提示哪些应用依赖它（错误码 `50015`）

**声明示例：**

```json
{
    "dependencies": {
        "app_ids": ["finance", "points"],
        "versions": {}
    }
}
```

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| app_ids | array | 依赖的应用ID数组，应用ID对应 `manifest.json` 中的 `id` 字段 |
| versions | object | 版本约束映射，键为应用ID，值为版本约束表达式（当前预留，暂未校验） |

**依赖链示例：**

```
finance（财务管理） ← 被依赖
points（积分管理）  ← 被依赖
    ↓
onlinepay（在线支付） ← 依赖 finance + points
payclient（聚合支付客户端） ← 依赖 finance + points
```

> 安装 `onlinepay` 或 `payclient` 前，必须先安装并启用 `finance` 和 `points` 应用。

***

## 应用命名规范

### app_id 格式

**应用标识（id）必须**采用 `开发者唯一标识.应用标识` 的命名格式（即 `xx.xx` 格式），这是强制性规则，所有新开发的应用必须遵守：

```
格式：{developer_slug}.{app_name}
示例：cmspro.blog、zhangsan.shop
```

#### 规则

| 部分 | 规则 | 正则 |
|------|------|------|
| developer_slug | 小写字母开头，仅允许小写字母和数字 | `[a-z][a-z0-9]*` |
| app_name | 小写字母开头，允许小写字母、数字和下划线 | `[a-z][a-z0-9_]*` |
| 连接符 | 点号（.） | - |
| 总长度 | 不超过 64 字符 | - |

#### 旧格式兼容

系统安装时已有的应用（如 `onlinepay`、`versionmgr`）保持原有 app_id 不变。**新开发的应用必须**使用 `开发者唯一标识.应用标识`（`xx.xx`）格式，不符合此格式的应用将无法通过安装校验。

### 各层映射规则

| 维度 | 旧格式（如 `onlinepay`） | 新格式（如 `cmspro.blog`） |
|------|------------------------|--------------------------|
| 目录名 | `Onlinepay/` | `CmsproBlog/` |
| 命名空间 | `App\Apps\Onlinepay\` | `App\Apps\CmsproBlog\` |
| 表前缀 | `app_onlinepay_` | `app_cmspro_blog_` |
| 路由前缀 | `/admin/onlinepay` | `/admin/cmspro/blog` |
| 静态资源 | `public/apps/onlinepay` | `public/apps/cmspro/blog` |
| 配置键 | `apps.onlinepay` | `apps.cmspro.blog` |

**映射规则**：
- 目录名/命名空间：点号分隔的每段 `ucfirst()` 后拼接（如 `cmspro.blog` → `CmsproBlog`）
- 表前缀：点号替换为下划线（如 `cmspro.blog` → `app_cmspro_blog_`）
- 路由前缀：点号替换为斜杠（如 `cmspro.blog` → `cmspro/blog`）
- 静态资源：同路由前缀规则
- 配置键：保持点号不变（如 `apps.cmspro.blog`）

### 开发者标识

开发者标识即注册时的**用户名**，作为应用ID的前缀（`xx.xx` 格式中的前段），确保不同开发者的同名应用不会冲突。**这是应用 id 的必填组成部分，不可省略**。

示例：
- 开发者 `cmspro` 发布的博客应用：`cmspro.blog`
- 开发者 `zhangsan` 发布的博客应用：`zhangsan.blog`

***

## 四、ServiceProvider 开发

ServiceProvider 是应用的运行时入口，负责注册路由、视图、配置等资源。

### 模板

```php
<?php

namespace App\Apps\Blog;

use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider as BaseServiceProvider;

class ServiceProvider extends BaseServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            base_path('app/Apps/Blog/Config/blog.php'),
            'apps.blog'
        );
    }

    public function boot(): void
    {
        $this->registerRoutes();
        $this->loadViews();
        $this->registerHooks();
    }

    protected function registerRoutes(): void
    {
        Route::prefix('admin/blog')
            ->namespace('App\Apps\Blog\Controllers')
            ->middleware(['auth:admin'])
            ->group(base_path('app/Apps/Blog/Routes/web.php'));
    }

    protected function loadViews(): void
    {
        $this->loadViewsFrom(
            base_path('app/Apps/Blog/Views'),
            'blog'
        );
    }

    protected function registerHooks(): void
    {
        $manifest = json_decode(
            file_get_contents(base_path('app/Apps/Blog/manifest.json')),
            true
        );

        if (isset($manifest['hooks'])) {
            $hooksClass = "App\\Apps\\Blog\\{$manifest['hooks']}";
            if (class_exists($hooksClass)) {
                $instance = new $hooksClass;
                $instance->register(app(\App\Services\HookManager::class));
            }
        }
    }
}
```

### 关键规则

1. **命名空间与目录名一致**：目录名必须为 `app_id` 的首字母大写形式（如 `versionmgr` → `Versionmgr`），因为系统通过 `ucfirst(app_id)` 构造命名空间和文件路径。如果目录名不匹配（如使用 `VersionManager` 而非 `Versionmgr`），路由将无法注册导致 404
2. **数据库 path 字段必须为相对路径**：安装后需确认 `apps` 表中的 `path` 字段值为相对路径格式（如 `app/Apps/Versionmgr`）。若为绝对路径（如 `E:\wwwroot\cmspro\code\app\Apps\Versionmgr`）会导致路由加载失败
3. **命名空间**：必须使用 `App\Apps\{AppName}\` 前缀，其中 `{AppName}` 是应用ID的首字母大写形式（即目录名）
4. **路由前缀**：所有路由必须以 `admin/{app_id}` 为前缀
5. **中间件**：必须添加 `auth:admin` 中间件
6. **视图命名空间**：使用应用ID作为命名空间，如 `blog`
7. **配置键**：使用 `apps.{app_id}` 作为配置键前缀

### ⚠️ 命名空间与路径常见问题

| 问题                        | 现象                                        | 排查方法                                                                       | 解决方案                         |
| ------------------------- | ----------------------------------------- | -------------------------------------------------------------------------- | ---------------------------- |
| 目录名与 app\_id 大小写不一致       | 路由 404，`class_exists()` 返回 false          | 检查 `AppServiceProvider::registerEnabledApps()` 日志，会输出 `CLASS_NOT_FOUND` 信息 | 目录重命名为 `ucfirst(app_id)` 的结果 |
| 数据库 path 为绝对路径            | 路由加载报错 `require(): Failed to open stream` | 查询 `SELECT id,app_id,path FROM apps WHERE app_id='xxx'`                    | 更新为相对路径 `app/Apps/{AppName}` |
| ServiceProvider 中硬编码了错误路径 | 路由加载报错文件不存在                               | 检查 `ServiceProvider.php` 中 `base_path()` 参数                                | 改为正确的相对路径                    |

> **排查命令**：当应用路由 404 时，可临时在 `AppServiceProvider.php` 的 `registerEnabledApps()` 方法中添加日志输出 catch 异常信息，定位是类找不到还是路径错误。

***

## 五、路由开发

### 路由文件

创建 `Routes/web.php`：

```php
<?php

use Illuminate\Support\Facades\Route;

Route::get('/', [PostController::class, 'index']);
Route::get('/create', [PostController::class, 'create']);
Route::post('/', [PostController::class, 'store']);
Route::get('/{id}/edit', [PostController::class, 'edit']);
Route::put('/{id}', [PostController::class, 'update']);
Route::delete('/{id}', [PostController::class, 'destroy']);
```

### 视图路由

如果需要返回 Blade 页面，在路由中直接返回视图：

```php
Route::get('/', function () {
    return view('blog::index');
});
```

### API 路由

API 路由同样在 `Routes/web.php` 中定义，因为系统使用 Session 认证：

```php
Route::get('/api/posts', [PostController::class, 'apiIndex']);
Route::post('/api/posts', [PostController::class, 'apiStore']);
```

***

## 六、视图开发

### 视图文件

视图文件放在 `Views/` 目录下，使用命名空间访问：

```php
// 在控制器或路由中
return view('blog::index');
return view('blog::posts.form', ['post' => $post]);
```

### 使用后台布局

应用视图可以继承系统后台布局：

```blade
@extends('layouts.admin')

@section('content')
<div class="pear-container">
    <div class="layui-card">
        <div class="layui-card-body">
            <!-- 应用内容 -->
        </div>
    </div>
</div>
@endsection

@section('script')
layui.use(['table', 'form', 'jquery'], function(){
    // JavaScript 逻辑
});
@endsection
```

### 引用静态资源

应用静态资源通过符号链接访问：

```html
<link rel="stylesheet" href="{{ asset('apps/cmspro.blog/css/style.css') }}">
<script src="{{ asset('apps/cmspro.blog/js/app.js') }}"></script>
<img src="{{ asset('apps/cmspro.blog/icon.png') }}">
```

> **⚠️ 禁止使用 CDN 引用 UI 资源**：系统已内置 layui（`public/CmsProUi/component/layui/`）、pear（`public/CmsProUi/component/pear/`）、font-awesome（`public/CmsProUi/font-awesome/`）等 UI 框架，引用这些系统资源时**必须使用本地路径**。如需使用其他第三方库（如 echarts、qrcodejs、highlight.js 等），应下载到应用的 `Assets/` 目录中，通过 `public/apps/{appId}/` 访问。禁止在视图文件中直接引用 `cdn.jsdelivr.net`、`cdnjs.cloudflare.com`、`unpkg.com` 等 CDN 域名。

### Assets 静态资源发布机制

应用的 `Assets/` 目录用于存放需要通过 Web 公开访问的静态资源（CSS、JS、字体、图片等）。系统在安装、升级、卸载时会自动管理 `Assets/` 目录与 `public/apps/{appId}/` 之间的映射关系，**应用无需自行处理资源复制**。

#### 自动发布流程

系统 `AppInstallerService` 在应用生命周期中自动处理资源发布：

| 操作 | 调用方法 | 行为 |
| ---- | -------- | ---- |
| 安装 | `createAssetSymlink()` | 将 `Assets/` 目录链接/复制到 `public/apps/{appId}/` |
| 升级 | `removeAssetSymlink()` → `createAssetSymlink()` | 先删除旧资源，再重新发布 |
| 卸载 | `removeAssetSymlink()` | 删除 `public/apps/{appId}/` 目录 |

#### 发布策略

- **Linux 环境**：优先创建符号链接（`symlink`），`public/apps/{appId}` 指向 `Assets/` 目录
- **Windows 环境**：`symlink()` 可能不可用，降级为 `File::copyDirectory()` 将 `Assets/` 内容复制到 `public/apps/{appId}/`
- **目标路径已存在时跳过**：如果 `public/apps/{appId}/` 已存在，`createAssetSymlink()` 不会重复处理

#### 目录结构映射

```
app/Apps/Blog/Assets/          →  public/apps/blog/
├── css/                       →  ├── css/
│   └── style.css              →  │   └── style.css
├── js/                        →  ├── js/
│   └── app.js                 →  │   └── app.js
└── fonts/                     →  └── fonts/
    └── icon.ttf               →      └── icon.ttf
```

#### ⚠️ 关键注意事项

1. **禁止在 Install.php 中创建 `public/apps/{appId}/` 下的目录**：安装流程的执行顺序是先调用 `Install::install()`，再调用 `createAssetSymlink()`。如果在 `Install::install()` 中提前创建了 `public/apps/{appId}/` 下的任何目录，`createAssetSymlink()` 检测到目标路径已存在后会直接跳过，导致 `Assets/` 下的文件不会被复制/链接

```php
// ❌ 错误：提前创建目录会阻止框架的资源发布
public function install(): void
{
    $this->runMigrations();
    mkdir(public_path('apps/blog/uploads'), 0755, true); // 这会导致 Assets 不被复制
}

// ✅ 正确：不在 Install.php 中创建 public/apps/{appId}/ 下的目录
public function install(): void
{
    $this->runMigrations();
}
```

2. **运行时上传目录不应放在 Assets 中**：`Assets/` 目录的文件由系统在安装/升级时统一管理，用户上传的文件应存放在 `storage/` 或其他独立目录中，避免升级时被 `removeAssetSymlink()` 清除

3. **升级时 Assets 内容会被刷新**：升级流程先删除 `public/apps/{appId}/` 再重新发布，因此 `Assets/` 中应只包含应用自带的静态资源，不要存放运行时生成的文件

***

## 七、数据库迁移

### 命名规范

应用创建的数据库表必须使用 `app_{app_id}_` 前缀：

```
app_blog_posts          → Blog 应用的文章表
app_blog_categories     → Blog 应用的分类表
app_blog_tags           → Blog 应用的标签表
```

### 迁移文件

在 `Migrations/` 目录下创建迁移文件：

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        if (Schema::hasTable('app_blog_posts')) {
            return;
        }

        Schema::create('app_blog_posts', function (Blueprint $table) {
            $table->bigIncrements('id')->comment('主键ID');
            $table->string('title')->comment('文章标题');
            $table->text('content')->nullable()->comment('文章内容');
            $table->unsignedTinyInteger('status')->default(0)->comment('状态：0-草稿 1-已发布 2-已下架');
            $table->timestamp('create_time')->nullable()->comment('创建时间');
            $table->timestamp('update_time')->nullable()->comment('更新时间');

            $table->index('status');
        });

        DB::statement("ALTER TABLE `app_blog_posts` COMMENT '博客文章表'");
    }

    public function down(): void
    {
        Schema::dropIfExists('app_blog_posts');
    }
};
```

### ⚠️ 迁移幂等性规范

迁移的 `up()` 和 `down()` 方法必须具备幂等性——即重复执行不会报错。这是因为在安装、升级、卸载流程中，迁移可能被多次调用（系统 `Artisan::call('migrate')` + `Install::install()` 兜底），如果缺少幂等检查会导致 `SQLSTATE[42S01]: Table already exists` 或 `SQLSTATE[42S02]: Base table or view not found` 错误。

#### 建表迁移的幂等性

```php
public function up(): void
{
    if (Schema::hasTable('app_blog_posts')) {
        return;
    }

    Schema::create('app_blog_posts', function (Blueprint $table) {
        // ...
    });
}

public function down(): void
{
    Schema::dropIfExists('app_blog_posts');
}
```

> `Schema::dropIfExists()` 本身已具备幂等性（表不存在时不报错），但 `Schema::create()` 不具备——重复建表会抛异常，因此 `up()` 必须添加表存在性检查。

#### 加列迁移的幂等性

当通过迁移为已有表添加字段时，`up()` 和 `down()` 都需要检查表和列的存在性：

```php
public function up(): void
{
    if (!Schema::hasTable('app_blog_posts')) {
        return;
    }

    Schema::table('app_blog_posts', function (Blueprint $table) {
        if (!Schema::hasColumn('app_blog_posts', 'summary')) {
            $table->string('summary', 500)->nullable()->after('title')->comment('摘要');
        }
        if (!Schema::hasColumn('app_blog_posts', 'sort')) {
            $table->integer('sort')->default(0)->after('status')->comment('排序');
        }
    });
}

public function down(): void
{
    if (!Schema::hasTable('app_blog_posts')) {
        return;
    }

    Schema::table('app_blog_posts', function (Blueprint $table) {
        $columns = [];
        if (Schema::hasColumn('app_blog_posts', 'summary')) $columns[] = 'summary';
        if (Schema::hasColumn('app_blog_posts', 'sort')) $columns[] = 'sort';
        if (!empty($columns)) $table->dropColumn($columns);
    });
}
```

> **关键场景**：卸载时系统先调 `Install::uninstall()` 再调 `rollbackMigrations()`。如果 `down()` 缺少表存在性检查，在表已被清理的情况下执行 `ALTER TABLE ... DROP COLUMN` 会报 `Table not found` 错误，导致卸载失败。

#### 幂等性速查表

| 操作类型 | up() 检查 | down() 检查 |
| ------- | --------- | ----------- |
| 建表 `Schema::create` | `Schema::hasTable()` → return | `Schema::dropIfExists()`（自带幂等） |
| 加列 `$table->string()` | `Schema::hasTable()` + `Schema::hasColumn()` | `Schema::hasTable()` + `Schema::hasColumn()` |
| 加索引 `$table->index()` | `Schema::hasTable()` | `Schema::hasTable()` |
| 改列 `$table->change()` | `Schema::hasTable()` + `Schema::hasColumn()` | `Schema::hasTable()` + `Schema::hasColumn()` |

### 备注规范

**表和字段必须添加备注**，这是数据库可维护性的基本要求，方便团队成员理解每个表和字段的业务含义。

#### 表备注

使用 `DB::statement()` 在 `up()` 方法中添加表备注：

```php
DB::statement("ALTER TABLE `app_blog_posts` COMMENT '博客文章表'");
```

#### 字段备注

使用 Laravel Schema Builder 的 `->comment()` 方法为每个字段添加备注：

```php
$table->string('title')->comment('文章标题');
$table->unsignedTinyInteger('status')->default(0)->comment('状态：0-草稿 1-已发布 2-已下架');
```

#### 备注编写规则

| 规则    | 说明                   | 示例                             |
| ----- | -------------------- | ------------------------------ |
| 语义清晰  | 备注必须说明字段的业务含义，而非数据类型 | ✅ `状态：0-禁用 1-启用` ❌ `tinyint类型` |
| 枚举值说明 | 状态、类型等枚举字段必须列出所有可选值  | `类型：1-Web 2-移动端 3-服务端`         |
| 关联说明  | 外键字段需说明关联的表          | `分类ID，关联app_blog_categories表`  |
| 简洁明了  | 备注应精炼，避免冗余描述         | ✅ `用户名` ❌ `用户的名字用于登录系统`        |

#### 基础字段备注参考

系统规范的基础字段，备注应统一如下：

| 字段            | 推荐备注                    |
| ------------- | ----------------------- |
| `id`          | 主键ID                    |
| `status`      | 状态：0-禁用 1-启用（根据业务调整枚举值） |
| `create_time` | 创建时间                    |
| `update_time` | 更新时间                    |

### 时间字段

遵循系统规范，使用 `create_time` 和 `update_time`（非 Laravel 默认）。

***

## 八、模型开发

### 模型模板

```php
<?php

namespace App\Apps\Blog\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $table = 'app_blog_posts';

    public const CREATED_AT = 'create_time';

    public const UPDATED_AT = 'update_time';

    protected $fillable = [
        'title',
        'content',
        'status',
    ];

    protected $casts = [
        'status' => \App\Enums\Status::class,
    ];
}
```

***

## 九、钩子系统

### 钩子类型

| 类型     | 说明           | 返回值       |
| ------ | ------------ | --------- |
| Action | 在特定节点执行自定义逻辑 | 无         |
| Filter | 在特定节点拦截并修改数据 | 必须返回修改后的值 |

### 注册钩子

创建 `Hooks.php`：

```php
<?php

namespace App\Apps\Blog;

use App\Services\HookManager;

class Hooks
{
    public function register(HookManager $hooks): void
    {
        $hooks->registerAction('content.after_create', [$this, 'onContentCreated'], 10, 'blog');
        $hooks->registerFilter('content.list.query', [$this, 'filterContentQuery'], 10, 'blog');
    }

    public function onContentCreated($content): void
    {
        // 内容创建后的自定义逻辑
    }

    public function filterContentQuery($query)
    {
        return $query->where('status', 1);
    }
}
```

**重要**：注册钩子时必须传入 `app_id`（第4个参数），以便应用禁用时自动移除钩子。

### 系统内置钩子点

| 钩子点                     | 类型     | 参数                 |
| ----------------------- | ------ | ------------------ |
| `content.before_create` | Filter | $data              |
| `content.after_create`  | Action | $content           |
| `content.before_update` | Filter | $data, $content    |
| `content.after_update`  | Action | $content           |
| `content.before_delete` | Action | $content           |
| `content.after_delete`  | Action | $content           |
| `content.list.query`    | Filter | $query             |
| `user.after_login`      | Action | $user              |
| `user.before_logout`    | Action | $user              |
| `menu.rendering`        | Filter | $menus             |
| `app.installed`         | Action | $appId             |
| `app.uninstalled`       | Action | $appId             |
| `app.enabled`           | Action | $appId             |
| `app.disabled`          | Action | $appId             |
| `app.upgraded`          | Action | $appId, $from, $to |

### 触发自定义钩子

应用也可以定义自己的钩子点供其他应用使用：

```php
$hookManager = app(\App\Services\HookManager::class);

// 触发 Action
$hookManager->doAction('blog.post.published', $post);

// 触发 Filter
$query = $hookManager->applyFilter('blog.post.query', $query);
```

***

## 十、Install 脚本

### 模板

创建 `Install.php` 可在安装/卸载/升级时执行自定义逻辑：

```php
<?php

namespace App\Apps\Blog;

class Install
{
    public function install(): void
    {
        // 在迁移执行之后调用
        // 如：创建默认数据、初始化设置等
    }

    public function uninstall(): void
    {
        // 在迁移回滚之前调用
        // 仅用于清理缓存、通知外部服务等非数据库操作
        // 禁止在此方法中删除数据库表（详见下方卸载规范）
    }

    public function upgrade(string $fromVersion, string $toVersion): void
    {
        // 在增量迁移执行之后调用
        // 如：数据格式转换、配置迁移等

        if (version_compare($fromVersion, '1.1.0', '<')) {
            // 1.0.x 升级到 1.1.0 的特殊处理
        }
    }
}
```

### 执行顺序

**安装**：Install::install() → 数据库迁移 → 注册菜单/配置/权限

**卸载**：Install::uninstall() → 回滚迁移 → 删除菜单/配置/权限

**升级**：Install::upgrade() → 增量迁移 → 更新菜单/权限

### ⚠️ 卸载规范

**`uninstall()` 方法中禁止删除数据库表。** 系统卸载流程是两步走：

1. 先调 `Install::uninstall()` 执行自定义清理逻辑
2. 再调 `rollbackMigrations()` 执行迁移的 `down()` 方法回滚数据库

如果在 `uninstall()` 中手动调用 `Schema::dropIfExists()` 删除了表，第 2 步 `down()` 方法再对已不存在的表执行 `ALTER TABLE ... DROP COLUMN` 等操作时会报 `Table not found` 错误，导致卸载失败。

```php
// ❌ 错误：手动删表会导致 rollbackMigrations() 报错
public function uninstall(): void
{
    Schema::dropIfExists('app_blog_posts');
    Schema::dropIfExists('app_blog_categories');
}

// ✅ 正确：卸载时仅做非数据库清理，表删除交给系统的 rollbackMigrations()
public function uninstall(): void
{
    Cache::flush();
}
```

> **原则**：数据库表的创建和删除完全由迁移文件的 `up()` / `down()` 方法负责，`Install::uninstall()` 只处理缓存清理、外部通知等非数据库操作。

### ⚠️ 卸载迁移回滚兜底

系统卸载流程第 2 步 `rollbackMigrations()` 使用 `Artisan::call('migrate:rollback', ['--path' => ...])` 回滚数据库。该命令依赖 `migrations` 表记录和路径解析，在 Windows 等环境下可能因路径分隔符、大小写等问题导致 `down()` 未执行，造成**卸载后数据库表和数据残留**。重装时 `up()` 的 `Schema::hasTable()` 检查发现表已存在会跳过，旧数据原封不动保留。

**解决方案**：在 `uninstall()` 中增加迁移回滚兜底，直接 `require` 迁移文件并调用 `down()` 方法，确保表一定被清理。同时删除 `migrations` 表中的对应记录，避免系统后续调 `rollbackMigrations()` 时因记录已删除而跳过（不会冲突）。

```php
public function uninstall(): void
{
    $this->rollbackMigrations();

    Cache::flush();
}

protected function rollbackMigrations(): void
{
    $migrationsPath = __DIR__ . '/Migrations';
    $migrationFiles = glob($migrationsPath . '/*.php');

    foreach (array_reverse($migrationFiles) as $file) {
        $migrationName = pathinfo($file, PATHINFO_FILENAME);

        if (!DB::table('migrations')->where('migration', $migrationName)->exists()) {
            continue;
        }

        $migration = require $file;
        if (method_exists($migration, 'down')) {
            try {
                $migration->down();

                DB::table('migrations')->where('migration', $migrationName)->delete();
            } catch (\Throwable $e) {
                Log::error('应用迁移回滚失败', [
                    'app' => 'blog',
                    'file' => basename($file),
                    'error' => $e->getMessage(),
                ]);
            }
        }
    }
}
```

关键设计说明：

| 要点 | 说明 |
| ---- | ---- |
| 逆序回滚 | `array_reverse()` 确保后建的表先删，避免外键约束问题 |
| 检查 migrations 记录 | 仅回滚 `migrations` 表中有记录的迁移，跳过未执行的迁移 |
| 删除 migrations 记录 | 回滚成功后删除记录，避免系统 `rollbackMigrations()` 重复执行 |
| down() 幂等性 | `down()` 必须使用 `Schema::dropIfExists()` 等幂等方法，确保重复调用不报错 |
| 与系统 rollbackMigrations 不冲突 | 先删 `migrations` 记录，系统后续调 `migrate:rollback` 时找不到记录，不会重复执行 |

### 迁移兜底机制

系统安装流程会自动执行 `Migrations/` 目录下的迁移文件。但在某些环境下（如 Windows 路径分隔符问题），迁移可能未正确执行。为确保安装可靠性，建议在 `Install.php` 的 `install()` 方法中增加迁移兜底逻辑：

```php
<?php

namespace App\Apps\Blog;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class Install
{
    public function install(): void
    {
        $this->runMigrations();
        // 其他初始化逻辑（如种子数据）...
    }

    public function uninstall(): void
    {
        $this->rollbackMigrations();

        Cache::flush();
    }

    public function upgrade(string $fromVersion, string $toVersion): void
    {
        $this->runMigrations();
    }

    protected function runMigrations(): void
    {
        $migrationsPath = __DIR__ . '/Migrations';
        $migrationFiles = glob($migrationsPath . '/*.php');

        foreach ($migrationFiles as $file) {
            $migrationName = pathinfo($file, PATHINFO_FILENAME);

            if (DB::table('migrations')->where('migration', $migrationName)->exists()) {
                continue;
            }

            $migration = require $file;
            if (method_exists($migration, 'up')) {
                try {
                    $migration->up();

                    DB::table('migrations')->insert([
                        'migration' => $migrationName,
                        'batch' => DB::table('migrations')->max('batch') + 1,
                    ]);
                } catch (\Throwable $e) {
                    Log::error('应用迁移执行失败', [
                        'app' => 'blog',
                        'file' => basename($file),
                        'error' => $e->getMessage(),
                    ]);
                }
            }
        }
    }

    protected function rollbackMigrations(): void
    {
        $migrationsPath = __DIR__ . '/Migrations';
        $migrationFiles = glob($migrationsPath . '/*.php');

        foreach (array_reverse($migrationFiles) as $file) {
            $migrationName = pathinfo($file, PATHINFO_FILENAME);

            if (!DB::table('migrations')->where('migration', $migrationName)->exists()) {
                continue;
            }

            $migration = require $file;
            if (method_exists($migration, 'down')) {
                try {
                    $migration->down();

                    DB::table('migrations')->where('migration', $migrationName)->delete();
                } catch (\Throwable $e) {
                    Log::error('应用迁移回滚失败', [
                        'app' => 'blog',
                        'file' => basename($file),
                        'error' => $e->getMessage(),
                    ]);
                }
            }
        }
    }
}
```

> **注意**：此兜底机制使用 `__DIR__` 定位迁移文件，避免路径分隔符兼容性问题。`require` 迁移文件后直接调用 `$migration->up()` 创建表，绕过 `Artisan::call('migrate')` 在事务上下文中可能失败的问题。执行成功后必须向 `migrations` 表插入记录，防止 `php artisan migrate` 重复执行。卸载时同理，直接调用 `$migration->down()` 删除表，绕过 `Artisan::call('migrate:rollback')` 路径解析可能失败的问题，回滚成功后删除 `migrations` 表记录，防止系统 `rollbackMigrations()` 重复执行。

***

## 十一、菜单声明

在 `manifest.json` 中声明菜单：

### 基本结构（独立顶级菜单）

```json
{
    "menus": [
        {
            "title": "博客管理",
            "icon": "fa fa-book",
            "path": "/admin/blog",
            "order": 100,
            "children": [
                {
                    "title": "文章管理",
                    "icon": "fa fa-file-text",
                    "path": "/admin/blog/posts",
                    "order": 1
                },
                {
                    "title": "分类管理",
                    "icon": "fa fa-folder",
                    "path": "/admin/blog/categories",
                    "order": 2
                }
            ]
        }
    ]
}
```

### 挂载到指定父菜单（parent 字段）

如果希望应用的菜单插入到已有的系统菜单下（而非创建新的顶级菜单），可使用 `parent` 字段：

```json
{
    "menus": [
        {
            "title": "版本管理",
            "icon": "layui-icon layui-icon-refresh",
            "path": "",
            "order": 4,
            "parent": 15,
            "children": [
                { "title": "版本总览", "icon": "fa fa-info-circle", "path": "/admin/versionmgr", "order": 1 },
                { "title": "升级管理", "icon": "fa fa-upload", "path": "/admin/versionmgr/packages", "order": 2 }
            ]
        }
    ]
}
```

**效果**：当指定了 `parent` 时，不会创建「版本管理」这个中间级菜单，其 `children` 会直接挂载到「系统维护」（ID=15）菜单下。

### parent 字段说明

| 值类型            | 示例          | 匹配规则            |
| -------------- | ----------- | --------------- |
| 菜单名称（字符串）      | `"系统维护"`    | 按 `name` 字段精确匹配 |
| 菜单路径（以 `/` 开头） | `"/system"` | 按 `path` 字段精确匹配 |
| 菜单 ID（数字）      | `14`        | 按 `id` 精确查找     |

**优先级**：ID > 路径 > 名称。**推荐使用 ID**，因为菜单名称可能被管理员修改导致匹配失败。

### 菜单字段说明

| 字段         | 类型         | 必填    | 说明                                           |
| ---------- | ---------- | ----- | -------------------------------------------- |
| title      | string     | 是     | 菜单显示名称                                       |
| icon       | string     | 否     | 图标 CSS 类名                                    |
| path       | string     | 否     | 菜单路径，用于路由跳转。顶级分组菜单可为空字符串                     |
| order      | integer    | 否     | 排序值，越小越靠前                                    |
| **parent** | string/int | **否** | **父菜单标识（名称/路径/ID），指定后将 children 直接挂载到该父菜单下** |
| children   | array      | 否     | 子菜单列表                                        |

> **注意**：使用 `parent` 字段时，当前菜单项本身不会被创建为可见菜单，其 `children` 将直接成为父菜单的子项。卸载应用时仅删除 `app_id` 匹配的菜单，**不影响父菜单**。

### 系统内置父菜单参考

| 名称   | 路径      | ID | 说明         |
| ---- | ------- | -- | ---------- |
| 系统   | -       | 1  | 系统根目录      |
| 系统账号 | /system | 13 | 用户、角色管理    |
| 系统设置 | -       | 14 | 站点设置、系统配置  |
| 系统维护 | -       | 15 | 日志、附件、数据管理 |
| 首页   | -       | 17 | 控制台首页      |
| 管理   | -       | 25 | 内容管理等业务模块  |
| 应用   | -       | 34 | 已装应用、应用市场  |

菜单会在安装时自动注册到 `admin_menus` 表，卸载时自动清除（仅清除 `app_id` 匹配的记录）。

### 用户端菜单声明（user\_menus）

应用可以同时注册后台管理菜单和用户端菜单。在 `manifest.json` 中使用 `user_menus` 字段声明用户端菜单，结构与 `menus` 完全一致，但注册时自动设置 `terminal_type='user'`：

```json
{
    "menus": [
        {
            "title": "博客管理",
            "icon": "fa fa-book",
            "path": "/admin/blog",
            "order": 100,
            "children": [
                { "title": "文章管理", "icon": "fa fa-file-text", "path": "/admin/blog/posts", "order": 1 }
            ]
        }
    ],
    "user_menus": [
        {
            "title": "我的博客",
            "icon": "fa fa-book",
            "path": "/user/blog",
            "order": 50,
            "children": [
                { "title": "我的文章", "icon": "fa fa-file-text", "path": "/user/blog/posts", "order": 1 },
                { "title": "写文章", "icon": "fa fa-pencil", "path": "/user/blog/create", "order": 2 }
            ]
        }
    ]
}
```

`user_menus` 同样支持 `parent` 字段，可挂载到用户端已有父菜单下。用户端内置父菜单参考：

| 名称   | 路径 | 说明     |
| ---- | -- | ------ |
| 用户中心 | -  | 用户端根目录 |

> **注意**：`user_menus` 中菜单的 `path` 必须以 `/user/` 开头，与用户端路由前缀对应。卸载应用时，`user_menus` 注册的菜单同样通过 `app_id` 自动清除。

### 官网前端菜单声明（home\_menus）

应用可以在 `manifest.json` 中使用 `home_menus` 字段声明官网前端导航菜单，注册时自动设置 `terminal_type='home'`：

```json
{
    "home_menus": [
        { "title": "首页", "path": "/", "order": 1 },
        { "title": "系统介绍", "path": "/about", "order": 2 },
        { "title": "新闻动态", "path": "/news", "order": 3 },
        { "title": "帮助中心", "path": "/help", "order": 4 }
    ]
}
```

`home_menus` 与 `menus`/`user_menus` 的区别：

| 维度 | `menus` | `user_menus` | `home_menus` |
| ---- | ------- | ------------ | ------------ |
| 终端类型 | `admin` | `user` | `home` |
| 认证要求 | 需登录 | 需登录 | 公开访问 |
| 支持子菜单 | 是（children） | 是（children） | 否（扁平结构） |
| 支持父级挂载 | 是（parent） | 是（parent） | 否 |
| 路由前缀 | `/admin/{appId}` | `/user/{appId}` | 无前缀或自定义 |

> **注意**：`home_menus` 为扁平结构，不支持 `children` 和 `parent` 字段。官网前端菜单通常为一级导航，无需层级嵌套。卸载应用时，`home_menus` 注册的菜单同样通过 `app_id` 自动清除。

***

## 十二、配置声明

在 `manifest.json` 中声明配置组，安装时系统会自动将配置组和配置项注册到数据库。

### 基本结构

```json
{
    "config_groups": [
        {
            "name": "blog_settings",
            "title": "博客设置",
            "items": [
                {
                    "name": "posts_per_page",
                    "title": "每页文章数",
                    "type": "number",
                    "value": "10"
                },
                {
                    "name": "allow_comments",
                    "title": "允许评论",
                    "type": "switch",
                    "value": "1"
                },
                {
                    "name": "default_category",
                    "title": "默认分类",
                    "type": "select",
                    "value": "1",
                    "options": [
                        { "label": "技术", "value": "1" },
                        { "label": "生活", "value": "2" },
                        { "label": "随笔", "value": "3" }
                    ]
                }
            ]
        }
    ]
}
```

### 配置项类型

| 类型       | 说明   | value 格式         |
| -------- | ---- | ---------------- |
| text     | 文本输入 | 字符串              |
| number   | 数字输入 | 数字字符串（如 `"10"`）  |
| switch   | 开关   | `"0"` 或 `"1"`    |
| select   | 下拉选择 | 需配合 `options` 字段 |
| textarea | 多行文本 | 字符串              |
| image    | 图片选择 | 图片路径字符串          |
| password | 密码输入 | 字符串（后台显示为掩码）     |

### 字段说明

#### 配置组字段

| 字段    | 类型     | 必填 | 说明                   |
| ----- | ------ | -- | -------------------- |
| name  | string | 是  | 配置组标识，仅允许小写字母、数字、下划线 |
| title | string | 是  | 配置组显示名称              |
| items | array  | 是  | 配置项列表                |

#### 配置项字段

| 字段      | 类型     | 必填 | 说明                                    |
| ------- | ------ | -- | ------------------------------------- |
| name    | string | 是  | 配置项标识，仅允许小写字母、数字、下划线                  |
| title   | string | 是  | 配置项显示名称                               |
| type    | string | 是  | 配置项类型，见上方类型表                          |
| value   | string | 否  | 默认值，所有类型均以字符串形式存储                     |
| options | array  | 否  | select 类型的选项列表，每项包含 `label` 和 `value` |
| tips    | string | 否  | 配置项提示信息，显示在输入框下方，用于说明配置项用途或格式要求       |

### 命名空间与存储规则

系统会自动为应用配置添加前缀，确保不同应用之间的配置不会冲突：

**配置组 code 规则：** `app_{应用id}_{配置组name}`

例如应用 `blog` 中 `name` 为 `blog_settings` 的配置组，存储到数据库的 code 为 `app_blog_blog_settings`。

**配置项 code 规则：** `app_{应用id}_{配置项name}`

例如应用 `blog` 中 `name` 为 `posts_per_page` 的配置项，存储到数据库的 code 为 `app_blog_posts_per_page`。

> **注意：** 在 `manifest.json` 中只需写原始 name，系统在安装时自动添加前缀。不要在 name 中手动添加 `app_` 前缀。

### 读取配置

在代码中通过数据库直接读取应用配置：

```php
use App\Models\ConfigItem;

$item = ConfigItem::where('code', 'app_blog_posts_per_page')->first();
$perPage = $item ? (int) $item->value : 10;
```

也可以通过应用配置服务读取：

```php
use App\Services\AppManagerService;

$manager = app(AppManagerService::class);
$result = $manager->getAppConfig('blog');
```

### ⚠️ 注意事项

1. **name 命名规范：** 配置组和配置项的 `name` 只能使用小写字母、数字和下划线，不要使用中文或特殊字符。
2. **配置项唯一性：** 不同应用的配置项 `name` 可以相同（系统会自动加前缀区分），但同一应用内的配置项 `name` 必须唯一。
3. **系统配置组不可删除：** 系统内置的配置组（基础配置、上传配置、安全配置、邮箱设置）受保护，不允许通过 API 删除或修改其 code。
4. **升级时配置同步：** 应用升级时，系统会自动同步 `manifest.json` 中新增的配置组和配置项。已有配置项的值不会被覆盖（用户修改过的配置会保留）。
5. **卸载时配置清除：** 应用卸载时，该应用的所有配置组和配置项会被级联删除，此操作不可逆。
6. **select 类型必须提供 options：** 使用 `select` 类型时，`options` 字段为必填，格式为 `[{ "label": "显示文本", "value": "存储值" }]`。
7. **value 统一为字符串：** 无论配置项类型是 number 还是 switch，`value` 字段都以字符串形式存储和声明（如 `"10"` 而非 `10`，`"1"` 而非 `true`）。
8. **tips 提示信息：** `tips` 字段会在配置表单中输入框下方显示为灰色提示文字，建议为以下类型的配置项填写 tips：
   - **URL 类配置**：提示完整路径格式，如 `"路径：{域名}/api/onlinepay/v1/notify/wechat"`
   - **路径类配置**：说明相对路径基准目录，如 `"相对于storage路径，如certs/wechat/apiclient_key.pem"`
   - **密钥类配置**：说明密钥格式要求，如 `"RSA2私钥，不含头尾标记"`
   - **数值类配置**：说明取值范围和单位，如 `"超时时间，单位：分钟，范围1-1440"`
   - tips 在安装时写入数据库，升级时已有记录的 tips 会被更新为最新值（value 不会被覆盖）

***

## 十三、权限声明

在 `manifest.json` 中声明权限，**推荐使用对象格式**（支持中文名称）：

### 对象格式（推荐）

```json
{
    "permissions": [
        { "code": "blog.manage", "name": "博客管理" },
        { "code": "blog.post.create", "name": "创建文章" },
        { "code": "blog.post.edit", "name": "编辑文章" },
        { "code": "blog.post.delete", "name": "删除文章" },
        { "code": "blog.category.manage", "name": "分类管理" }
    ]
}
```

### 字段说明

| 字段   | 类型     | 必填 | 说明                                    |
| ---- | ------ | -- | ------------------------------------- |
| code | string | 是  | 权限标识码，格式为 `{app_id}.{功能点}`，用于代码中的权限判断 |
| name | string | 是  | 权限中文名称，显示在角色管理的权限列表中。未指定时降级使用 code 值  |

### 命名规范

- **code 值**：仅允许小写字母、数字和点号，格式为 `{app_id}.{模块}.{操作}`
- **name 值**：必须使用中文，简洁明了地描述权限含义

### 示例对照表

| code                        | name  | 说明       |
| --------------------------- | ----- | -------- |
| `versionmgr.manage`         | 版本管理  | 最高管理权限   |
| `versionmgr.check`          | 版本检查  | 查看版本信息   |
| `versionmgr.upgrade`        | 执行升级  | 执行系统升级操作 |
| `versionmgr.package.upload` | 上传升级包 | 上传升级包文件  |
| `payment.order.view`        | 查看订单  | 只读查看订单列表 |
| `payment.channel.create`    | 创建渠道  | 新增支付渠道   |

### 兼容说明

系统同时兼容旧版字符串格式（向后兼容），但新应用应统一使用对象格式：

```json
// 旧格式（仍可正常工作，但不推荐）
"permissions": ["blog.manage", "blog.post.create"]

// 新格式（推荐）
"permissions": [
    { "code": "blog.manage", "name": "博客管理" },
    { "code": "blog.post.create", "name": "创建文章" }
]
```

权限会在安装时自动注册到 `admin_permissions` 表（`name` 字段写入中文），卸载时自动清除。管理员需要在角色管理中分配权限后才能生效。

### 代码中使用权限

```php
// 控制器或中间件中判断权限
if (!auth('admin')->user()->can('blog.post.create')) {
    abort(403, '无权操作');
}

// 路由定义时绑定权限
Route::post('/posts', [PostController::class, 'store'])
    ->middleware('can:blog.post.create');
```

***

## 十四、应用打包

### 包结构

将应用目录打包为 `.zip` 文件：

```
blog-1.0.0.zip
├── manifest.json
├── ServiceProvider.php
├── Hooks.php
├── Install.php
├── Routes/
├── Controllers/
├── Models/
├── Services/
├── Migrations/
├── Views/
├── Config/
└── Assets/
```

### 打包命令

```bash
cd app/Apps/Blog
zip -r ../../../storage/app_packages/blog-1.0.0.zip ./*
```

### 包命名规则

`{app_id}-{version}.zip`，例如 `blog-1.0.0.zip`

***

## 十五、应用生命周期

### 状态流转

```
安装 → INSTALLED（已安装，未启用）
  ↓ enable()
ENABLED（已启用，运行中）
  ↓ disable()
DISABLED（已禁用，暂停运行）
  ↓ enable()
ENABLED
  ↓ uninstall()
删除
```

### 启用/禁用的影响

| 操作 | 路由 | 菜单 | 钩子 | 数据 |
| -- | -- | -- | -- | -- |
| 启用 | 注册 | 显示 | 注册 | 保留 |
| 禁用 | 移除 | 隐藏 | 移除 | 保留 |
| 卸载 | 移除 | 删除 | 移除 | 删除 |

***

## 十六、错误码

| 错误码   | 说明                    |
| ----- | --------------------- |
| 50001 | 应用包格式无效               |
| 50002 | manifest.json 缺失或格式错误 |
| 50003 | 系统要求不满足               |
| 50004 | 依赖应用未安装               |
| 50005 | 应用已安装                 |
| 50006 | 应用未安装                 |
| 50007 | 应用状态不允许此操作            |
| 50008 | 安装过程失败                |
| 50009 | 卸载过程失败                |
| 50010 | 升级过程失败                |
| 50011 | 签名验证失败                |
| 50012 | 市场服务不可用               |
| 50013 | 应用下载失败                |
| 50014 | 系统应用不可卸载              |
| 50015 | 有其他应用依赖此应用            |
| 50017 | 依赖应用未启用               |
| 50019 | 前台路由冲突               |

***

## 十七、完整示例：博客应用

### manifest.json

```json
{
    "id": "blog",
    "name": "博客",
    "description": "博客应用，支持文章发布、分类、标签管理",
    "version": "1.0.0",
    "author": "CmsPro",
    "require": {
        "php": ">=8.1",
        "cmspro": ">=5.0.0"
    },
    "providers": ["ServiceProvider"],
    "hooks": "Hooks",
    "install": "Install",
    "menus": [
        {
            "title": "博客管理",
            "icon": "fa fa-book",
            "path": "/admin/blog",
            "order": 100,
            "children": [
                { "title": "文章管理", "icon": "fa fa-file-text", "path": "/admin/blog/posts", "order": 1 },
                { "title": "分类管理", "icon": "fa fa-folder", "path": "/admin/blog/categories", "order": 2 }
            ]
        }
    ],
    "config_groups": [
        {
            "name": "blog_settings",
            "title": "博客设置",
            "items": [
                { "name": "posts_per_page", "title": "每页文章数", "type": "number", "value": "10", "tips": "每页显示的文章数量" },
                { "name": "allow_comments", "title": "允许评论", "type": "switch", "value": "1", "tips": "关闭后文章将不显示评论区" },
                { "name": "default_category", "title": "默认分类", "type": "select", "value": "1", "options": [
                    { "label": "技术", "value": "1" },
                    { "label": "生活", "value": "2" },
                    { "label": "随笔", "value": "3" }
                ]}
            ]
        }
    ],
    "permissions": [
        { "code": "blog.manage", "name": "博客管理" },
        { "code": "blog.post.create", "name": "创建文章" },
        { "code": "blog.post.edit", "name": "编辑文章" },
        { "code": "blog.post.delete", "name": "删除文章" }
    ]
}
```

### ServiceProvider.php

```php
<?php

namespace App\Apps\Blog;

use App\Services\HookManager;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider as BaseServiceProvider;

class ServiceProvider extends BaseServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            base_path('app/Apps/Blog/Config/blog.php'),
            'apps.blog'
        );
    }

    public function boot(): void
    {
        $this->registerRoutes();
        $this->loadViews();
        $this->registerHooks();
    }

    protected function registerRoutes(): void
    {
        Route::prefix('admin/blog')
            ->namespace('App\Apps\Blog\Controllers')
            ->middleware(['auth:admin'])
            ->group(base_path('app/Apps/Blog/Routes/web.php'));
    }

    protected function loadViews(): void
    {
        $this->loadViewsFrom(
            base_path('app/Apps/Blog/Views'),
            'blog'
        );
    }

    protected function registerHooks(): void
    {
        $hooks = $this->app->make(HookManager::class);
        $hooksInstance = new Hooks();
        $hooksInstance->register($hooks);
    }
}
```

### Install.php

```php
<?php

namespace App\Apps\Blog;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class Install
{
    public function install(): void
    {
        $this->runMigrations();
        // 创建默认分类等种子数据...
    }

    public function uninstall(): void
    {
        // 仅做非数据库清理（如缓存）
    }

    public function upgrade(string $fromVersion, string $toVersion): void
    {
        $this->runMigrations();
    }

    protected function runMigrations(): void
    {
        $migrationsPath = __DIR__ . '/Migrations';
        $migrationFiles = glob($migrationsPath . '/*.php');

        foreach ($migrationFiles as $file) {
            $migrationName = pathinfo($file, PATHINFO_FILENAME);

            if (DB::table('migrations')->where('migration', $migrationName)->exists()) {
                continue;
            }

            $migration = require $file;
            if (method_exists($migration, 'up')) {
                try {
                    $migration->up();

                    DB::table('migrations')->insert([
                        'migration' => $migrationName,
                        'batch' => DB::table('migrations')->max('batch') + 1,
                    ]);
                } catch (\Throwable $e) {
                    Log::error('应用迁移执行失败', [
                        'app' => 'blog',
                        'file' => basename($file),
                        'error' => $e->getMessage(),
                    ]);
                }
            }
        }
    }
}
```

### Hooks.php

```php
<?php

namespace App\Apps\Blog;

use App\Services\HookManager;

class Hooks
{
    public function register(HookManager $hooks): void
    {
        $hooks->registerAction('content.after_create', [$this, 'onContentCreated'], 10, 'blog');
        $hooks->registerFilter('content.list.query', [$this, 'filterContentQuery'], 10, 'blog');
    }

    public function onContentCreated($content): void
    {
        // 同步到博客文章表
    }

    public function filterContentQuery($query)
    {
        return $query;
    }
}
```

### Migrations/2026\_05\_22\_000001\_create\_app\_blog\_posts\_table.php

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        if (Schema::hasTable('app_blog_posts')) {
            return;
        }

        Schema::create('app_blog_posts', function (Blueprint $table) {
            $table->bigIncrements('id')->comment('主键ID');
            $table->string('title')->comment('文章标题');
            $table->text('content')->nullable()->comment('文章内容');
            $table->unsignedBigInteger('category_id')->nullable()->comment('分类ID，关联app_blog_categories表');
            $table->unsignedTinyInteger('status')->default(0)->comment('状态：0-草稿 1-已发布 2-已下架');
            $table->timestamp('create_time')->nullable()->comment('创建时间');
            $table->timestamp('update_time')->nullable()->comment('更新时间');

            $table->index('category_id');
            $table->index('status');
        });

        DB::statement("ALTER TABLE `app_blog_posts` COMMENT '博客文章表'");
    }

    public function down(): void
    {
        Schema::dropIfExists('app_blog_posts');
    }
};
```

### Routes/web.php

```php
<?php

use App\Apps\Blog\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('blog::index');
});
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/create', [PostController::class, 'create']);
Route::post('/posts', [PostController::class, 'store']);
Route::get('/posts/{id}/edit', [PostController::class, 'edit']);
Route::put('/posts/{id}', [PostController::class, 'update']);
Route::delete('/posts/{id}', [PostController::class, 'destroy']);
```

***

## 十八、注意事项

1. **禁止修改系统核心文件**：应用代码不应修改 `app/Services/`、`app/Models/`、`config/` 等系统目录中的文件
2. **数据库表必须加前缀**：应用创建的表必须使用 `app_{app_id}_` 前缀
3. **钩子注册必须传 app\_id**：确保应用禁用时钩子能被正确移除
4. **视图使用命名空间**：避免与系统视图或其他应用视图冲突
5. **配置使用前缀键**：通过 `apps.{app_id}` 访问，避免与系统配置冲突
6. **卸载必须可逆**：迁移的 `down()` 方法必须正确回滚
7. **遵循系统规范**：时间字段使用 `create_time/update_time`，状态字段使用枚举类型
8. **表和字段必须添加备注**：迁移文件中每个字段必须使用 `->comment()` 添加备注，每张表必须使用 `DB::statement("ALTER TABLE ... COMMENT '...'")` 添加表备注。无备注的迁移文件不予合并
9. **目录名与 app\_id 严格对应**：应用目录名必须是 `ucfirst(app_id)` 的结果（如 `versionmgr` → `Versionmgr`，而非 `VersionManager`）。系统通过此规则自动构造命名空间和文件路径，不一致将导致路由 404
10. **安装后检查 path 字段**：安装完成后需确认 `apps` 表中该应用的 `path` 字段为相对路径（如 `app/Apps/Versionmgr`）。若为绝对路径需手动修正，否则路由加载失败
11. **权限 name 必须使用中文**：`manifest.json` 中 `permissions` 推荐使用对象格式 `{ "code": "xxx", "name": "中文名称" }`，`name` 字段写入 `admin_permissions` 表后显示在角色管理的权限列表中
12. **Install.php 建议增加迁移兜底**：由于系统迁移执行依赖路径解析，Windows 等环境下可能失败。建议在 `install()` 方法中通过 `require` 迁移文件并调用 `$migration->up()` 作为兜底，确保表一定能被创建
13. **用户端菜单使用 user\_menus 声明**：应用如需在用户端（`/user/`）展示菜单，必须使用 `user_menus` 字段声明，不可将用户端菜单放在 `menus` 字段中
14. **迁移 up()/down() 必须幂等**：`up()` 中建表前检查 `Schema::hasTable()`，加列前检查 `Schema::hasColumn()`；`down()` 中删列前检查表和列是否存在。缺少幂等检查会导致安装/卸载/升级流程中报 `Table already exists` 或 `Table not found` 错误
15. **uninstall() 必须包含迁移回滚兜底**：卸载时系统会自动调用 `rollbackMigrations()` 执行迁移的 `down()` 方法，但该方法依赖 `Artisan::call('migrate:rollback')` 在 Windows 等环境下可能因路径解析失败导致 `down()` 未执行。因此 `uninstall()` 中必须增加 `rollbackMigrations()` 兜底方法，直接 `require` 迁移文件并调用 `down()` 确保表被清理，同时删除 `migrations` 表记录避免系统后续重复执行。禁止在 `uninstall()` 中使用 `Schema::dropIfExists()` 手动删表（绕过了 `down()` 的幂等检查），应统一通过迁移的 `down()` 方法删除
16. **runMigrations() 必须写入 migrations 表记录**：兜底执行迁移后，必须向 `migrations` 表插入记录，否则 `php artisan migrate` 会重复执行已跑过的迁移
17. **用户端视图禁止继承 layouts.user**：PearAdmin 用户端采用 iframe 多标签页模式，子页面通过 iframe 加载到 `layui-body` 区域。用户端视图必须是独立完整 HTML 页面（有自己的 `<head>`/`<body>`/CSS/JS），使用 `@extends('layouts.user')` 会导致布局嵌套显示。详见第十九章 19.6 节
18. **Blade 与 Layui 语法冲突**：Blade 视图中 Layui 模板语法 `{{# }}` 的 `{{` 会被 Blade 解析为 PHP 表达式，导致 `syntax error, unexpected token ";"` 错误。必须用 `@verbatim` / `@endverbatim` 包裹包含 Layui `{{# }}` 语法的 `<script type="text/html">` 块。详见第十九章 19.6 节
19. **依赖外部服务的应用应提供未配置友好提示**：应用如依赖外部服务（如支付服务端），应在控制器中检查配置状态，未配置时显示友好提示页面（而非空白或报错），API 接口返回明确错误码和提示信息。详见第十九章 19.6 节
20. **禁止在 Install.php 中创建 public/apps/{appId}/ 下的目录**：安装流程先执行 `Install::install()`，再执行 `createAssetSymlink()` 发布 `Assets/` 资源。如果在 `install()` 中提前创建了 `public/apps/{appId}/` 下的目录，`createAssetSymlink()` 检测到目标路径已存在后会跳过，导致 `Assets/` 下的文件不会被复制。详见第六章「Assets 静态资源发布机制」

***

## 十九、用户端扩展

CmsPro v5 支持应用同时扩展后台管理和用户端两个终端。用户端地址为 `/user/`，使用 `auth:web` Guard 认证前台用户，界面与后台管理完全一致（Pear Admin + Layui + iframe 多标签页）。

### 19.1 用户端与后台对比

| 维度       | 后台管理                    | 用户端                    |
| -------- | ----------------------- | ---------------------- |
| 入口路径     | `/admin/`               | `/user/`               |
| 认证 Guard | `auth:admin`            | `auth:web`             |
| 用户模型     | `AdminUser`             | `User`                 |
| 菜单终端     | `terminal_type='admin'` | `terminal_type='user'` |
| 菜单声明     | `manifest.menus`        | `manifest.user_menus`  |
| 路由前缀     | `admin/{appId}`         | `user/{appId}`         |
| API 前缀   | `api/admin/`            | `api/user/`            |
| 布局模板     | `layouts.admin`         | `layouts.user`         |
| 权限控制     | RBAC 角色权限               | 无（所有启用用户可见）            |

### 19.2 manifest.json 声明

在 `manifest.json` 中使用 `user_menus` 字段声明用户端菜单：

```json
{
    "id": "blog",
    "name": "博客",
    "menus": [
        {
            "title": "博客管理",
            "icon": "fa fa-book",
            "path": "/admin/blog",
            "order": 100,
            "children": [
                { "title": "文章管理", "icon": "fa fa-file-text", "path": "/admin/blog/posts", "order": 1 }
            ]
        }
    ],
    "user_menus": [
        {
            "title": "我的博客",
            "icon": "fa fa-book",
            "path": "/user/blog",
            "order": 50,
            "children": [
                { "title": "我的文章", "icon": "fa fa-file-text", "path": "/user/blog/posts", "order": 1 },
                { "title": "写文章", "icon": "fa fa-pencil", "path": "/user/blog/create", "order": 2 }
            ]
        }
    ]
}
```

### 19.3 ServiceProvider 注册用户端路由

在 ServiceProvider 中注册用户端路由组，使用 `user/{appId}` 前缀和 `auth:web` 中间件：

```php
protected function registerRoutes(): void
{
    // 后台路由
    Route::prefix('admin/blog')
        ->namespace('App\Apps\Blog\Controllers\Admin')
        ->middleware(['web', 'auth:admin'])
        ->group(base_path('app/Apps/Blog/Routes/admin.php'));

    // 用户端视图路由
    Route::prefix('user/blog')
        ->namespace('App\Apps\Blog\Controllers\User')
        ->middleware(['web', 'auth:web'])
        ->group(base_path('app/Apps/Blog/Routes/user.php'));

    // 用户端 API 路由（可选）
    Route::prefix('api/user/blog')
        ->namespace('App\Apps\Blog\Controllers\User')
        ->middleware(['web', 'auth:web'])
        ->group(base_path('app/Apps/Blog/Routes/user_api.php'));
}
```

### 19.4 目录结构

支持用户端的应用推荐目录结构：

```
app/Apps/Blog/
├── manifest.json
├── ServiceProvider.php
├── Controllers/
│   ├── Admin/                  # 后台控制器
│   │   └── PostController.php
│   └── User/                   # 用户端控制器
│       └── PostController.php
├── Routes/
│   ├── admin.php               # 后台路由
│   ├── user.php                # 用户端路由
│   └── user_api.php            # 用户端 API 路由（可选）
├── Views/
│   ├── Admin/                  # 后台视图
│   │   └── posts/
│   └── User/                   # 用户端视图
│       └── posts/
├── Models/                     # 共用模型
├── Services/                   # 共用服务
└── Migrations/                 # 共用迁移
```

### 19.5 用户端路由文件

`Routes/user.php` — 用户端视图路由：

```php
<?php

use App\Apps\Blog\Controllers\User\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', [PostController::class, 'index']);
Route::get('/create', [PostController::class, 'create']);
Route::post('/', [PostController::class, 'store']);
Route::get('/{id}/edit', [PostController::class, 'edit']);
Route::put('/{id}', [PostController::class, 'update']);
Route::delete('/{id}', [PostController::class, 'destroy']);
```

`Routes/user_api.php` — 用户端 API 路由（可选，用于 AJAX 请求）：

```php
<?php

use App\Apps\Blog\Controllers\User\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/posts', [PostController::class, 'apiIndex']);
Route::post('/posts', [PostController::class, 'apiStore']);
```

### 19.6 视图开发（后台与用户端）

> **⚠️ 重要：后台和用户端视图都必须为独立完整 HTML 页面，禁止继承 `layouts.admin` 或 `layouts.user` 布局**

PearAdmin 后台和用户端框架均采用 **iframe 多标签页** 模式：`layouts.admin` / `layouts.user` 是外层框架页面（包含顶部导航、侧边菜单、标签栏），点击菜单时通过 iframe 加载子页面到 `layui-body` 区域。因此，后台和用户端的子页面都必须是独立完整的 HTML 页面（有自己的 `<head>`/`<body>`/CSS/JS），**不能**使用 `@extends('layouts.admin')` 或 `@extends('layouts.user')` 继承布局，否则会导致页面嵌套显示——外层渲染了完整框架布局，内层又渲染了一遍框架+内容，表现为"上半部分是框架首页，下半部分才是正常内容"。

#### ❌ 错误写法（会导致布局嵌套）

后台和用户端都**禁止**使用 `@extends` 继承布局：

```blade
{{-- ❌ 后台视图错误写法 --}}
@extends('layouts.admin')

@section('content')
<div class="pear-container">
    <!-- 内容 -->
</div>
@endsection

@section('script')
layui.use(['table', 'form', 'jquery'], function(){
    // JavaScript 逻辑
});
@endsection
```

```blade
{{-- ❌ 用户端视图错误写法 --}}
@extends('layouts.user')

@section('content')
<div class="pear-container">
    <!-- 内容 -->
</div>
@endsection

@section('script')
layui.use(['table', 'form', 'jquery'], function(){
    // JavaScript 逻辑
});
@endsection
```

#### ✅ 正确写法（独立完整 HTML 页面）

后台和用户端视图的正确写法完全一致——都是独立完整 HTML 页面，自行引入 CSS/JS，唯一区别是 API 前缀和页面导航方式：

```blade
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>页面标题</title>
    <link rel="stylesheet" href="{{ asset('CmsProUi/component/pear/css/pear.css') }}">
    <link rel="stylesheet" href="{{ asset('CmsProUi/font-awesome/4.7.0/css/font-awesome.min.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/admin.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/variables.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/reset.css') }}">
</head>
<body>
<div class="pear-container">
    <div class="layui-card">
        <div class="layui-card-body">
            <!-- 页面内容 -->
        </div>
    </div>
</div>

<script src="{{ asset('CmsProUi/component/layui/layui.js') }}"></script>
<script src="{{ asset('CmsProUi/component/pear/pear.js') }}"></script>
<script>
layui.use(['table', 'form', 'jquery'], function(){
    var $ = layui.jquery;

    $.ajaxSetup({
        headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
    });

    // JavaScript 逻辑
});
</script>
</body>
</html>
```

> **⚠️ UI 资源引用本地化规范**：系统已内置 layui、pear、font-awesome 等 UI 框架，统一存放在 `public/CmsProUi/` 目录下。应用视图中引用这些系统级资源时，**必须使用 `{{ asset() }}` 引用本地路径，禁止通过 CDN 远程加载**。如需使用额外的第三方库（如 echarts、qrcodejs、highlight.js 等），应下载到应用自身的 `Assets/` 目录中，通过 `public/apps/{appId}/` 访问，禁止直接引用 CDN。

#### 视图开发要点

| 要点 | 说明 |
| ---- | ---- |
| 禁止继承布局 | 不要使用 `@extends('layouts.admin')` 或 `@extends('layouts.user')`，页面必须是独立完整 HTML |
| 自行引入 CSS | 引入 pear.css、font-awesome、admin.css、variables.css、reset.css 等样式文件 |
| 自行引入 JS | 引入 layui.js、pear.js，并使用 `layui.use()` 初始化组件 |
| CSRF Token | 必须通过 `$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' } })` 设置，否则 AJAX POST 请求会被 Laravel 拦截返回 419 错误 |
| 后台页面导航 | 后台子页面运行在 iframe 中，使用 `window.location.href` 或 `parent.PearAdmin.changePage()` 跳转均可 |
| 用户端页面导航 | 用户端子页面运行在 iframe 中，必须使用 `parent.PearAdmin.changePage({ id, title, url, type: '_iframe', close: true })` 跳转，不要使用 `window.location.href` |
| Layui 模板语法 | Layui 的 `{{# }}` 语法会被 Blade 解析为 PHP 表达式导致语法错误，必须用 `@verbatim` / `@endverbatim` 包裹（详见下方说明） |

**关键差异**：

| 项目     | 后台视图                                                  | 用户端视图                                                  |
| ------ | ------------------------------------------------------ | ------------------------------------------------------ |
| 布局方式   | **独立完整 HTML 页面**（禁止继承 `layouts.admin`）                 | **独立完整 HTML 页面**（禁止继承 `layouts.user`）                 |
| 当前用户   | `auth()->user()` → `AdminUser`                         | `auth()->user()` → `User`                              |
| API 前缀 | `/api/admin/`                                          | `/api/user/`                                           |
| 用户名字段  | `auth()->user()->name`                                 | `auth()->user()->nickname ?? auth()->user()->username` |
| 页面导航   | `window.location.href` 或 `parent.PearAdmin.changePage()` | `parent.PearAdmin.changePage()`                        |

#### ⚠️ Blade 与 Layui 语法冲突

后台和用户端视图虽然是独立 HTML 页面，但文件扩展名仍为 `.blade.php`，Blade 引擎会解析其中的 `{{ }}` 语法。Layui 的模板语法（如 `{{# if(d.status == 0){ }}`）恰好使用了 `{{` 前缀，会被 Blade 误解析为 PHP 表达式，导致 `syntax error, unexpected token ";"` 错误。

**解决方案**：使用 `@verbatim` / `@endverbatim` 包裹 Layui 模板块，告诉 Blade 不处理其中的内容：

```blade
<!-- ❌ 错误：Layui 模板语法被 Blade 解析导致报错 -->
<script type="text/html" id="status-tpl">
{{# if(d.status == 0){ }}
    <span class="layui-badge layui-bg-blue">待支付</span>
{{# } }}
</script>

<!-- ✅ 正确：用 @verbatim 包裹，Blade 不处理其中的内容 -->
<script type="text/html" id="status-tpl">
@verbatim
{{# if(d.status == 0){ }}
    <span class="layui-badge layui-bg-blue">待支付</span>
{{# } }}
@endverbatim
</script>
```

> **适用范围**：所有包含 Layui `{{# }}` 模板语法的 `<script type="text/html">` 块都需要 `@verbatim` 包裹。普通的 `<script>` 标签中的 JavaScript 代码不受影响（因为 JS 中不会出现 `{{` 语法）。

#### 未配置时友好提示

应用依赖外部服务（如支付服务端）时，如果用户在未完成配置的情况下访问功能页面，应给出友好提示而非直接报错。推荐做法：

1. **在 ConfigService 中提供配置检查方法**：

```php
public function isConfigured(): bool
{
    $appId = $this->get('app_id');
    $appSecret = $this->get('app_secret');

    return !empty($appId) && !empty($appSecret);
}
```

2. **在用户端控制器中检查配置状态**：

```php
public function index()
{
    if (!$this->configService->isConfigured()) {
        return view('payclient::User.not-configured');
    }

    return view('payclient::User.orders');
}
```

3. **在 API 控制器中返回明确错误**：

```php
public function orderList(Request $request)
{
    if (!$this->configService->isConfigured()) {
        return response()->json([
            'code' => 1001,
            'message' => '支付服务未配置，请联系管理员完成对接'
        ], 403);
    }
    // ...
}
```

4. **创建友好提示视图**（如 `Views/User/not-configured.blade.php`），说明配置步骤和操作指引，避免用户看到空白页面或技术错误信息。

### 19.7 用户端控制器

用户端控制器位于 `Controllers/User/` 目录，使用 `auth:web` Guard 获取当前用户：

```php
<?php

namespace App\Apps\Blog\Controllers\User;

use App\Apps\Blog\Models\Post;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index()
    {
        $user = auth()->user();
        $posts = Post::where('user_id', $user->id)->orderBy('create_time', 'desc')->get();

        return view('blog::User.posts.index', compact('posts'));
    }

    public function create()
    {
        return view('blog::User.posts.create');
    }
}
```

### 19.8 用户端 API 调用

用户端的 AJAX 请求调用 `/api/user/` 前缀的接口：

```javascript
// 后台
$.ajax({ url: '/api/admin/blog/posts', type: 'GET', ... });

// 用户端
$.ajax({ url: '/api/user/blog/posts', type: 'GET', ... });
```

### 19.9 完整示例：博客应用（双终端）

#### manifest.json

```json
{
    "id": "blog",
    "name": "博客",
    "description": "博客应用，支持文章发布、分类、标签管理",
    "version": "1.0.0",
    "author": "CmsPro",
    "require": {
        "php": ">=8.1",
        "cmspro": ">=5.0.0"
    },
    "providers": ["ServiceProvider"],
    "menus": [
        {
            "title": "博客管理",
            "icon": "fa fa-book",
            "path": "/admin/blog",
            "order": 100,
            "children": [
                { "title": "文章管理", "icon": "fa fa-file-text", "path": "/admin/blog/posts", "order": 1 },
                { "title": "分类管理", "icon": "fa fa-folder", "path": "/admin/blog/categories", "order": 2 }
            ]
        }
    ],
    "user_menus": [
        {
            "title": "我的博客",
            "icon": "fa fa-book",
            "path": "/user/blog",
            "order": 50,
            "children": [
                { "title": "我的文章", "icon": "fa fa-file-text", "path": "/user/blog/posts", "order": 1 },
                { "title": "写文章", "icon": "fa fa-pencil", "path": "/user/blog/create", "order": 2 }
            ]
        }
    ],
    "permissions": [
        { "code": "blog.manage", "name": "博客管理" },
        { "code": "blog.post.create", "name": "创建文章" },
        { "code": "blog.post.edit", "name": "编辑文章" },
        { "code": "blog.post.delete", "name": "删除文章" }
    ]
}
```

#### ServiceProvider.php

```php
<?php

namespace App\Apps\Blog;

use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider as BaseServiceProvider;

class ServiceProvider extends BaseServiceProvider
{
    public function boot(): void
    {
        $this->registerRoutes();
        $this->loadViews();
    }

    protected function registerRoutes(): void
    {
        Route::prefix('admin/blog')
            ->namespace('App\Apps\Blog\Controllers\Admin')
            ->middleware(['web', 'auth:admin'])
            ->group(base_path('app/Apps/Blog/Routes/admin.php'));

        Route::prefix('user/blog')
            ->namespace('App\Apps\Blog\Controllers\User')
            ->middleware(['web', 'auth:web'])
            ->group(base_path('app/Apps/Blog/Routes/user.php'));

        Route::prefix('api/user/blog')
            ->namespace('App\Apps\Blog\Controllers\User')
            ->middleware(['web', 'auth:web'])
            ->group(base_path('app/Apps/Blog/Routes/user_api.php'));
    }

    protected function loadViews(): void
    {
        $this->loadViewsFrom(
            base_path('app/Apps/Blog/Views'),
            'blog'
        );
    }
}
```

#### Routes/user.php

```php
<?php

use App\Apps\Blog\Controllers\User\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', [PostController::class, 'index']);
Route::get('/create', [PostController::class, 'create']);
Route::post('/', [PostController::class, 'store']);
Route::get('/{id}/edit', [PostController::class, 'edit']);
Route::put('/{id}', [PostController::class, 'update']);
Route::delete('/{id}', [PostController::class, 'destroy']);
```

#### Views/User/posts/index.blade.php

```blade
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>我的文章</title>
    <link rel="stylesheet" href="{{ asset('CmsProUi/component/pear/css/pear.css') }}">
    <link rel="stylesheet" href="{{ asset('CmsProUi/font-awesome/4.7.0/css/font-awesome.min.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/admin.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/variables.css') }}">
    <link rel="stylesheet" href="{{ asset('Admin/css/reset.css') }}">
</head>
<body>
<div class="pear-container">
    <div class="layui-card">
        <div class="layui-card-header">我的文章</div>
        <div class="layui-card-body">
            <table id="postTable" lay-filter="postTable"></table>
        </div>
    </div>
</div>

<script src="{{ asset('CmsProUi/component/layui/layui.js') }}"></script>
<script src="{{ asset('CmsProUi/component/pear/pear.js') }}"></script>
<script>
layui.use(['table', 'jquery'], function(){
    var table = layui.table;
    var $ = layui.jquery;

    $.ajaxSetup({
        headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
    });

    table.render({
        elem: '#postTable',
        url: '/api/user/blog/posts',
        cols: [[
            {field: 'id', title: 'ID', width: 80},
            {field: 'title', title: '标题'},
            {field: 'status', title: '状态', width: 100},
            {field: 'create_time', title: '创建时间', width: 180}
        ]]
    });
});
</script>
</body>
</html>
```

### 19.10 注意事项

1. **路由前缀区分**：后台路由使用 `admin/{appId}` 前缀 + `auth:admin` 中间件，用户端路由使用 `user/{appId}` 前缀 + `auth:web` 中间件，两者不可混用
2. **菜单 path 必须匹配**：`user_menus` 中的 `path` 必须以 `/user/` 开头，与用户端路由前缀对应
3. **控制器命名空间隔离**：后台控制器放 `Controllers/Admin/`，用户端控制器放 `Controllers/User/`，避免类名冲突
4. **视图目录隔离**：后台视图放 `Views/Admin/`，用户端视图放 `Views/User/`，共用视图命名空间
5. **模型和服务可共用**：`Models/` 和 `Services/` 目录下的代码在两个终端间共享
6. **用户对象差异**：后台 `auth()->user()` 返回 `AdminUser`，用户端返回 `User`，注意字段差异（如后台有 `name`，用户端有 `nickname`）
7. **API 前缀区分**：用户端 AJAX 请求使用 `/api/user/` 前缀，后台使用 `/api/admin/` 前缀
8. **仅声明需要的终端**：应用可以只声明 `menus`（仅后台）或只声明 `user_menus`（仅用户端），不必同时声明两者
9. **⚠️ 后台和用户端视图均禁止继承布局**：PearAdmin 后台和用户端均采用 iframe 多标签页模式，`layouts.admin` / `layouts.user` 是外层框架页面，子页面通过 iframe 加载。后台和用户端视图都必须是独立完整 HTML 页面（有自己的 `<head>`/`<body>`/CSS/JS），使用 `@extends('layouts.admin')` 或 `@extends('layouts.user')` 会导致布局嵌套——页面同时渲染外层框架和内层内容，表现为"上半部分是框架首页，下半部分才是正常内容"。正确做法参照 19.6 节
10. **用户端页面导航使用 parent.PearAdmin.changePage()**：用户端子页面运行在 iframe 中，`window.location.href` 跳转仅在 iframe 内生效。需要切换页面时应使用 `parent.PearAdmin.changePage({ id: '唯一标识', title: '页面标题', url: '/user/xxx', type: '_iframe', close: true })` 通知外层框架切换标签页
11. **后台和用户端视图必须设置 CSRF Token**：独立 HTML 页面不继承布局的 CSRF 设置，必须在 `<script>` 中通过 `$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' } })` 设置，否则 AJAX POST 请求会被 Laravel 拦截返回 419 错误
12. **⚠️ Layui 模板语法必须用 @verbatim 包裹**：Blade 视图中 Layui 的 `{{# }}` 模板语法会被 Blade 解析为 PHP 表达式导致语法错误，所有 `<script type="text/html">` 中包含 `{{# }}` 的内容必须用 `@verbatim` / `@endverbatim` 包裹。后台和用户端视图均适用。详见 19.6 节
13. **依赖外部服务的应用应提供未配置友好提示**：控制器中检查配置状态（如 `ConfigService::isConfigured()`），未配置时返回友好提示视图而非空白或报错，API 返回明确错误码和提示信息

***

## 二十、前端终端（Home）开发

CmsPro v5 支持三种终端类型：后台管理（`admin`）、用户端（`user`）、**前端（`home`）**。其中前端终端面向公开访问用户，无需认证，任何应用都可以注册自己的前端路由和页面，实现独立的前端展示能力。

系统内置「CMSPRO官网」应用（`cmsprohome`）作为默认的官网前端实现，同时其他应用（如 Payclient）也可以注册自己的前端页面。

### 20.1 三种终端对比

| 维度 | 后台管理 | 用户端 | 前端（Home） |
| ---- | ------- | ------ | ------------ |
| 入口路径 | `/admin/` | `/user/` | 自定义（通常根路径或 `/{appId}`） |
| 认证 Guard | `auth:admin` | `auth:web` | 无（公开访问） |
| 菜单终端 | `terminal_type='admin'` | `terminal_type='user'` | `terminal_type='home'` |
| 菜单声明 | `manifest.menus` | `manifest.user_menus` | `manifest.home_menus` |
| 路由前缀 | `admin/{appId}` | `user/{appId}` | 应用自定义 |
| API 前缀 | `api/admin/` | `api/user/` | 应用自定义 |
| 布局模板 | 系统级 `layouts.admin` | 系统级 `layouts.user` | **应用自带** |
| 权限控制 | RBAC 角色权限 | 无 | 无 |

### 20.2 前端终端架构

#### 核心原则

1. **应用自带布局**：每个应用的前端页面使用自己目录下的布局模板，不依赖系统级或其他应用的布局
2. **路由自注册**：前端路由在应用的 ServiceProvider 中注册，启用时自动加载，禁用时自动移除
3. **菜单自动管理**：通过 `manifest.json` 的 `home_menus` 字段声明导航菜单，安装/卸载时自动注册/清理
4. **完全独立**：禁用或卸载某个应用不影响其他应用的前端页面

#### 目录结构（以一个典型前端应用为例）

```
app/Apps/{AppName}/
├── manifest.json                  # 应用声明（含 home_menus）
├── ServiceProvider.php            # 服务注册（含前端路由）
├── Controllers/
│   ├── Home/                     # 前端视图控制器（可选）
│   │   └── ProductController.php # 前端页面控制器
│   └── Api/                      # 前端API控制器（可选）
│       └── ProductApiController.php
├── Routes/
│   ├── web.php                   # 后台+用户端路由
│   ├── admin.php                 # 后台管理路由
│   ├── user.php                  # 用户端路由
│   ├── api.php                   # 后台/用户端 API
│   ├── home.php                  # ★ 前端页面路由（新增）
│   └── front-api.php             # ★ 前端 API 路由（可选）
└── Views/
    ├── Admin/                    # 后台视图
    ├── User/                     # 用户端视图
    ├── layouts/
    │   └── home.blade.php        # ★ 前端布局（应用自带）
    └── Home/                     # ★ 前端页面视图（可选）
        └── index.blade.php
```

#### 已有示例

系统中已有两个实现前端终端的应用：

| 应用 | app_id | 前端路径 | 说明 |
| ---- | ------ | -------- | ---- |
| **CMSPRO官网** | `cmsprohome` | `/` | 系统内置官网，包含首页、新闻、帮助等完整页面 |
| **聚合支付客户端** | `payclient` | `/payclient/` | 产品介绍页和接入指南页 |

### 20.3 注册前端路由

在应用的 `ServiceProvider::registerRoutes()` 中添加前端路由注册：

```php
protected function registerRoutes(): void
{
    // 后台管理路由
    Route::prefix('admin/{appId}')
        ->middleware(['web', 'auth:admin'])
        ->group(base_path('app/Apps/{AppName}/Routes/admin.php'));

    // 用户端路由
    Route::prefix('user/{appId}')
        ->middleware(['web', 'auth:web'])
        ->group(base_path('app/Apps/{AppName}/Routes/user.php'));

    // ★ 前端路由（无需认证）
    Route::prefix('{appId}')                          // 自定义前缀，可为空字符串表示根路径
        ->middleware(['web'])                           // 仅 web 中间件，无认证
        ->group(base_path('app/Apps/{AppName}/Routes/home.php'));

    // ★ 前端 API（可选）
    Route::prefix('api/{appId}')
        ->middleware(['throttle:60,1'])
        ->group(base_path('app/Apps/{AppName}/Routes/front-api.php'));
}
```

> **重要**：前端路由必须放在最后注册，确保不会覆盖其他应用的路由。如果需要占用根路径 `/`，建议只允许一个应用（如 cmsprohome）这样做。

#### 路由文件示例

**Routes/home.php** — 前端页面路由：

```php
use App\Apps\{AppName}\Controllers\Home\ProductController;
use Illuminate\Support\Facades\Route;

Route::get('/', [ProductController::class, 'index']);
Route::get('/guide', [ProductController::class, 'guide']);
```

### 20.4 前端控制器与视图

#### 控制器

前端控制器继承 `Illuminate\Routing\Controller`（非系统的 `App\Http\Controllers\Controller`），渲染应用内的视图：

```php
<?php

namespace App\Apps\{AppName}\Controllers\Home;

use Illuminate\Routing\Controller;

class ProductController extends Controller
{
    public function index()
    {
        return view('{appId}::Home.index');      // 使用应用视图命名空间
    }

    public function guide()
    {
        return view('{appId}::Home.guide');
    }
}
```

#### 视图继承应用自带布局

```blade
{{-- Views/Home/index.blade.php --}}
@extends('{appId}::layouts.home')

@section('title', '产品首页')

@section('content')
<div class="hero-section">
    <h1>产品名称</h1>
    <p>产品描述</p>
</div>
@endsection
```

#### 加载视图

在 ServiceProvider 中注册视图命名空间：

```php
protected function loadViews(): void
{
    $this->loadViewsFrom(
        base_path('app/Apps/{AppName}/Views'),
        '{appId}'                                    // 视图命名空间，如 'cmsprohome'
    );
}
```

### 20.5 前端布局

每个应用的前端布局完全自主控制。以下是 Payclient 应用的简化布局示例：

```blade
{{-- Views/layouts/home.blade.php --}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>@yield('title', '应用名称')</title>
    <link rel="stylesheet" href="{{ asset('Home/css/home.css') }}">
</head>
<body>
<header class="app-header">
    <nav>@yield('nav')</nav>
</header>

<main>@yield('content')</main>

<footer class="app-footer">@yield('footer')</footer>

@php $siteAnalytics = \App\Models\ConfigItem::where('code', 'site_analytics')->value('value'); @endphp
@if($siteAnalytics)
{!! $siteAnalytics !!}
@endif
@yield('script')
@stack('page_scripts')
</body>
</html>
```

#### 系统统计代码接入

系统后台「基础配置」中提供了「统计代码」配置项（`site_analytics`），管理员可在此填入百度统计、Google Analytics 等第三方统计代码。应用前端布局**必须**接入此配置，确保统计代码在所有前台页面生效。

**接入方式**：在布局文件（或独立前端页面）的 `</body>` 标签前添加以下代码：

```blade
@php $siteAnalytics = \App\Models\ConfigItem::where('code', 'site_analytics')->value('value'); @endphp
@if($siteAnalytics)
{!! $siteAnalytics !!}
@endif
```

> **注意**：使用 `{!! !!}` 而非 `{{ }}` 输出，因为统计代码通常包含 `<script>` 标签，需要原样输出不被转义。

**不同场景的接入方式**：

| 场景 | 接入方式 | 示例 |
| ---- | -------- | ---- |
| 有统一布局文件 | 在布局文件 `</body>` 前添加代码 | `Views/layouts/home.blade.php` |
| 无统一布局（独立页面） | 创建 partial 文件，每个页面 `@include` 引入 | `@include('boxcode::front.partials.analytics')` |

partial 文件内容示例（`Views/front/partials/analytics.blade.php`）：

```blade
@php $siteAnalytics = \App\Models\ConfigItem::where('code', 'site_analytics')->value('value'); @endphp
@if($siteAnalytics)
{!! $siteAnalytics !!}
@endif
```

#### 布局设计要点

- **独立性**：布局文件位于应用内部，不依赖 `resources/views/layouts/`
- **样式引用**：可复用 `public/Home/css/home.css` 中的 CSS 变量体系，也可引入自定义样式
- **View Composer**：如需动态数据（如菜单），在 ServiceProvider 中注册 View Composer：

```php
View::composer('{appId}::layouts.home', function ($view) {
    $menus = AdminMenu::where('status', 1)
        ->where('visible', 1)
        ->where('terminal_type', 'home')
        ->where('app_id', '{appId}')              // 仅查询本应用的菜单
        ->orderBy('sort', 'asc')
        ->get();

    $view->with('homeMenus', $menus);
});
```

### 20.6 home\_menus 菜单声明

应用可以在 `manifest.json` 中使用 `home_menus` 字段声明前端导航菜单（详见第十一章）：

```json
{
    "id": "myapp",
    "name": "我的应用",
    "home_menus": [
        { "title": "首页", "path": "/myapp", "order": 1 },
        { "title": "功能介绍", "path": "/myapp/features", "order": 2 },
        { "title": "使用文档", "path": "/myapp/docs", "order": 3 }
    ]
}
```

`home_menus` 与 `menus` / `user_menus` 的区别：

| 维度 | `menus` | `user_menus` | `home_menus` |
| ---- | ------- | ------------ | ------------ |
| 终端类型 | `admin` | `user` | `home` |
| 认证要求 | 需登录 | 需登录 | 公开访问 |
| 支持子菜单 | 是（children） | 是（children） | 否（扁平结构） |
| 支持父级挂载 | 是（parent） | 是（parent） | 否 |
| 菜单归属 | 按 `app_id` 过滤 | 按 `app_id` 过滤 | 按 `app_id` 过滤 |

> **注意**：`home_menus` 为扁平结构，不支持 `children` 和 `parent` 字段。安装应用时自动创建 `terminal_type='home'` 且 `app_id` 匹配的菜单记录，卸载时自动清除。

### 20.7 静态资源

前端页面的静态资源统一存放在 `public/Home/` 目录下，所有前端应用共享：

```
public/Home/
├── css/
│   └── home.css               # 官网样式（CSS 变量体系）
├── logo.png                   # Logo
├── logo-nav.png              # 导航栏 Logo
├── Default/                   # 首页粒子动画资源
│   ├── Views.js
│   └── Index.js
└── admin.png                  # 后台截图等图片
```

视图中的引用方式：

```blade
<link rel="stylesheet" href="{{ asset('Home/css/home.css') }}">
<img src="{{ asset('Home/logo-nav.png') }}" alt="Logo">
```

如需应用专属静态资源，可在 `public/` 下创建子目录（如 `public/{appId}/`），并在视图中引用。

### 20.8 CSS 样式规范

共享样式文件 `public/Home/css/home.css` 采用 CSS 变量体系，所有前端应用均可使用：

| 变量 | 用途 | 默认值 |
| ---- | --- | ----- |
| `--primary` | 主色调 | `#2563eb` |
| `--text` | 正文色 | `#1e293b` |
| `--bg` | 背景色 | `#ffffff` |
| `--bg-gray` | 灰色背景 | `#f8fafc` |
| `--border` | 边框色 | `#e2e8f0` |
| `--radius` | 圆角 | `8px` |
| `--max-width` | 内容最大宽度 | `1200px` |

常用样式类：`.section`、`.section-inner`、`.section-title`、`.features-grid`、`.feature-card`、`.page-banner`、`.btn`、`.btn-primary`、`.pagination`、`.empty-state` 等。

### 20.9 为应用添加前端页面的步骤

1. **创建前端控制器**：在 `Controllers/Home/` 下创建控制器，返回应用视图
2. **创建前端布局**：在 `Views/layouts/home.blade.php` 创建自有布局
3. **创建前端视图**：在 `Views/Home/` 下创建 Blade 模板，继承 `'{appId}::layouts.home'`
4. **创建前端路由文件**：在 `Routes/home.php` 定义前端路由
5. **注册前端路由**：在 `ServiceProvider::registerRoutes()` 中注册 `Routes/home.php`
6. **声明菜单**（可选）：在 `manifest.json` 的 `home_menus` 中添加菜单项
7. **注册 View Composer**（可选）：如需动态菜单数据，在 ServiceProvider 中注册
8. **重新安装应用**：使 `home_menus` 生效

### 20.10 前台路由冲突检测

当多个应用都需要注册前台路由时（尤其是根路径 `/`），会产生路由覆盖冲突。Laravel 后注册的同路径路由会覆盖先注册的，导致其中一个应用的前台页面无法访问，且结果取决于 ServiceProvider 的 boot 执行顺序，行为不可预测。

#### home\_routes 声明

在 `manifest.json` 中使用 `home_routes` 字段声明应用占用的前台路由，系统在安装和启用时会自动检测冲突：

```json
{
    "id": "cmsprohome",
    "name": "CMSPRO官网",
    "home_routes": {
        "/": "首页",
        "/about": "系统介绍",
        "/news": "新闻动态",
        "/news/{id}": "新闻详情",
        "/apps": "应用市场",
        "/license": "授权查询",
        "/help": "帮助中心"
    }
}
```

**字段格式**：

| 维度 | 说明 |
| ---- | ---- |
| 键 | 路由路径，如 `/`、`/about`、`/news/{id}` |
| 值 | 路由中文名称，用于冲突提示中展示，如 "首页"、"系统介绍" |

> `home_routes` 与 `home_menus` 的区别：`home_menus` 声明导航菜单项（写入 `admin_menus` 表），`home_routes` 声明路由占用（仅用于冲突检测，不写入数据库）。两者路径可能重叠，但职责不同。

#### 冲突检测时机

| 时机 | 检测范围 | 行为 |
| ---- | -------- | ---- |
| **安装应用时** | 所有已安装应用（INSTALLED/DISABLED/ENABLED） | 拒绝安装，返回错误码 50019 |
| **启用应用时** | 所有已启用应用（ENABLED） | 拒绝启用，返回错误码 50019 |

安装时检测范围更广（包含未启用的应用），因为已安装未启用的应用随时可能被启用，提前预警避免后续冲突。启用时仅检测已启用的应用，因为只有已启用应用的路由才会实际注册。

#### 冲突提示示例

```
检测到前台路由冲突：
1. 路由 /：已被「CMSPRO官网」占用（首页），与「企业官网」冲突（首页）
2. 路由 /about：已被「CMSPRO官网」占用（系统介绍），与「企业官网」冲突（关于我们）
3. 路由 /news：已被「CMSPRO官网」占用（新闻动态），与「企业官网」冲突（新闻动态）
请先禁用或卸载冲突应用后再安装。
```

#### 处理方式

| 场景 | 处理方式 |
| ---- | -------- |
| 仅一个应用占用根路径 `/` | 推荐 cmsprohome 作为唯一根路径应用 |
| 多个应用各有前缀 | 各应用使用不同前缀，如 `/payclient/`、`/boxcode/` |
| 需要替换前台应用 | 先禁用/卸载当前前台应用，再安装/启用新应用 |
| 根路径被占用时的兜底 | 系统在 `routes/web.php` 中保留兜底路由，显示「暂未开放」提示 |

#### 根路径兜底

当 cmsprohome 应用被禁用时，系统根路径 `/` 显示兜底页面：

```php
// routes/web.php 中的兜底路由
Route::get('/', function () {
    return response()->view('home-disabled', [], 503);
})->name('home.disabled');
```

由于应用路由在 ServiceProvider 中动态注册，禁用应用后路由自动不注册，系统兜底路由生效。

### 20.11 注意事项

1. **公开访问**：前端页面无需认证，所有内容应对公开用户可见，**不应返回敏感数据**
2. **布局独立性**：每个应用必须自带前端布局，禁止依赖其他应用的布局文件
3. **路由顺序**：前端路由应在 ServiceProvider 中最后注册，避免覆盖其他应用的路由
4. **命名空间隔离**：视图使用 `{appId}::` 命名空间前缀，避免与其他应用冲突
5. **API 安全**：前端 API 接口应设置合理的频率限制（throttle），防止滥用
6. **菜单隔离**：`home_menus` 通过 `app_id` 字段自动过滤，各应用只能看到自己的菜单
7. **静态资源**：公共样式存放于 `public/Home/`，应用专属资源建议存放于 `public/{appId}/`
8. **生命周期一致**：启用应用 → 路由生效 → 菜单可见；禁用应用 → 路由移除 → 菜单隐藏
9. **声明 home\_routes 避免路由冲突**：注册前台路由的应用必须在 `manifest.json` 中声明 `home_routes` 字段，系统会在安装和启用时自动检测路由冲突并提示。未声明 `home_routes` 的应用不会参与冲突检测，但可能导致路由覆盖而无法访问
10. **接入系统统计代码**：应用前端布局必须在 `</body>` 前接入 `site_analytics` 系统配置，确保管理员在后台「基础配置→统计代码」中填写的第三方统计代码（百度统计、Google Analytics 等）能在所有前台页面生效。详见 20.5 节

***

## 二十一、应用域名绑定

CmsPro v5 支持应用前端通过**子域名**或**路径前缀**两种方式访问。应用可在 `manifest.json` 的 `config_groups` 中声明访问模式配置，ServiceProvider 根据配置动态选择 `Route::domain()` 或 `Route::prefix()` 注册前端路由，实现配置驱动的双模式访问。

### 21.1 两种访问模式对比

| 维度 | 路径前缀模式 | 子域名绑定模式 |
| ---- | ------------ | -------------- |
| 访问地址 | `example.com/forum/` | `forum.example.com/` |
| 路由注册 | `Route::prefix('forum')` | `Route::domain('forum.example.com')` |
| 配置复杂度 | 低（开箱即用） | 高（需 DNS + Web 服务器 + Session 配置） |
| SEO 友好度 | 一般 | 优（独立域名） |
| Session 共享 | 自动（同域） | 需配置 `SESSION_DOMAIN` |
| 适用场景 | 大多数应用 | 需要独立品牌域名的应用 |

### 21.2 配置项声明

在 `manifest.json` 的 `config_groups` 中声明以下三个配置项：

```json
{
    "config_groups": [
        {
            "name": "access_settings",
            "title": "访问设置",
            "items": [
                {
                    "name": "access_mode",
                    "title": "访问模式",
                    "type": "select",
                    "value": "path",
                    "options": [
                        { "label": "路径前缀", "value": "path" },
                        { "label": "子域名绑定", "value": "domain" }
                    ],
                    "tips": "路径前缀模式：通过 /forum/ 访问；子域名模式：通过 forum.example.com 访问"
                },
                {
                    "name": "access_domain",
                    "title": "绑定域名",
                    "type": "text",
                    "value": "",
                    "tips": "子域名模式下生效，支持多域名绑定，以英文逗号分隔，如 forum.example.com,bbs.example.com。需在 DNS 中添加解析并在 .env 中设置 SESSION_DOMAIN=.example.com"
                },
                {
                    "name": "access_path",
                    "title": "路径前缀",
                    "type": "text",
                    "value": "forum",
                    "tips": "路径前缀模式下生效，如设置为 forum 则通过 /forum/ 访问"
                }
            ]
        }
    ]
}
```

配置项存储到数据库后，code 格式为 `app_{应用id}_{name}`，如 `app_cmspro_forum_access_mode`。

### 21.3 ServiceProvider 路由注册

在 ServiceProvider 中根据配置动态注册前端路由：

```php
protected function registerHomeRoutes(): void
{
    $accessMode = $this->getConfigValue('access_mode', 'path');

    if ($accessMode === 'domain') {
        $domainConfig = $this->getConfigValue('access_domain', '');
        if (empty($domainConfig)) {
            return; // 未配置域名则不注册前端路由
        }

        // 支持逗号分隔的多域名，如 "forum.example.com,bbs.example.com"
        $domains = array_map('trim', explode(',', $domainConfig));
        $domains = array_filter($domains);

        foreach ($domains as $domain) {
            // 子域名模式
            Route::domain($domain)
                ->middleware(['web'])
                ->namespace('App\Apps\{AppName}\Controllers\Home')
                ->group(base_path('app/Apps/{AppName}/Routes/home.php'));

            Route::domain($domain)
                ->prefix('api/{appId}')
                ->middleware(['throttle:60,1'])
                ->namespace('App\Apps\{AppName}\Controllers\HomeApi')
                ->group(base_path('app/Apps/{AppName}/Routes/home-api.php'));
        }
    } else {
        // 路径前缀模式
        $path = $this->getConfigValue('access_path', '{appId}');
        Route::prefix($path)
            ->middleware(['web'])
            ->namespace('App\Apps\{AppName}\Controllers\Home')
            ->group(base_path('app/Apps/{AppName}/Routes/home.php'));

        Route::prefix('api/{appId}')
            ->middleware(['throttle:60,1'])
            ->namespace('App\Apps\{AppName}\Controllers\HomeApi')
            ->group(base_path('app/Apps/{AppName}/Routes/home-api.php'));
    }
}

/**
 * 从数据库读取配置值
 */
protected function getConfigValue(string $name, string $default = ''): string
{
    try {
        $value = \App\Models\ConfigItem::where('code', 'app_{应用id}_' . $name)->value('value');
        return $value ?? $default;
    } catch (\Throwable $e) {
        return $default;
    }
}
```

### 21.4 不影响 home 的保证

| 模式 | 应用访问 | home 访问 | 隔离机制 |
| ---- | -------- | --------- | -------- |
| 子域名 | `forum.example.com/*` | `www.example.com/*` | `Route::domain()` 仅匹配指定域名 |
| 路径前缀 | `example.com/forum/*` | `example.com/*`（根路径） | `Route::prefix()` 仅匹配指定前缀 |

两种模式下，home 模块（cmsprohome）的路由均不受影响：
- 子域名模式：`Route::domain('forum.example.com')` 仅响应 `forum.example.com` 域名的请求，`www.example.com` 的请求由 home 模块处理
- 路径前缀模式：`Route::prefix('forum')` 仅响应 `/forum/` 前缀的请求，根路径 `/` 由 home 模块处理

### 21.5 Session 域名配置

子域名模式下，主站和子域名需要共享 Session，否则用户在主站登录后访问子域名仍为未登录状态。

在 `.env` 中设置：

```
SESSION_DOMAIN=.example.com
```

> **注意**：`SESSION_DOMAIN` 前面的点号（`.`）表示 Cookie 对所有子域名生效。设置后主站和所有子域名共享同一 Session。

### 21.6 URL 生成辅助函数

由于前端路由的域名/前缀是动态配置的，不能硬编码 URL。建议在 ServiceProvider 的 `register()` 方法中注册辅助函数，根据当前配置自动生成正确的 URL：

```php
if (!function_exists('{appId}_url')) {
    function {appId}_url(string $path = ''): string
    {
        $accessMode = \App\Models\ConfigItem::where('code', 'app_{应用id}_access_mode')->value('value') ?? 'path';

        if ($accessMode === 'domain') {
            $domainConfig = \App\Models\ConfigItem::where('code', 'app_{应用id}_access_domain')->value('value') ?? '';
            $domains = array_map('trim', explode(',', $domainConfig));
            $domains = array_filter($domains);

            // 优先使用当前请求匹配的域名，否则取第一个
            $currentHost = request()->getHost();
            $domain = in_array($currentHost, $domains) ? $currentHost : ($domains[0] ?? '');

            if (!empty($domain)) {
                $scheme = request()->isSecure() ? 'https' : 'http';
                return rtrim("{$scheme}://{$domain}", '/') . '/' . ltrim($path, '/');
            }
        }

        $prefix = \App\Models\ConfigItem::where('code', 'app_{应用id}_access_path')->value('value') ?? '{appId}';
        return '/' . trim($prefix, '/') . '/' . ltrim($path, '/');
    }
}
```

视图中使用：

```blade
<a href="{{ forum_url('t/' . $topic->id) }}">{{ $topic->title }}</a>
```

### 21.7 子域名模式部署前置条件

使用子域名模式前，需完成以下配置：

| 步骤 | 说明 |
| ---- | ---- |
| DNS 解析 | 添加子域名 A 记录或 CNAME 记录，如 `forum.example.com → 服务器IP` |
| Web 服务器 | 配置虚拟主机，将子域名指向 CmsPro 入口文件（`code/public/index.php`） |
| Session 域名 | 在 `.env` 中设置 `SESSION_DOMAIN=.example.com` |
| 应用配置 | 在后台论坛设置中选择"子域名绑定"并填写绑定域名 |
| 缓存清除 | 修改配置后执行 `php artisan route:clear && php artisan config:clear` |

**Nginx 配置示例**：

```nginx
server {
    listen 80;
    server_name forum.example.com;
    root /path/to/cmspro/code/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}
```

### 21.8 多域名绑定

`access_domain` 配置项支持绑定多个域名，以英文逗号分隔：

```
forum.example.com,bbs.example.com,community.example.com
```

#### 路由注册

ServiceProvider 会将配置中的域名拆分后逐个注册 `Route::domain()`，每个域名都绑定完整的前端路由和 API 路由，用户通过任意一个域名均可正常访问。

#### URL 生成策略

辅助函数（如 `forum_url()`）在多域名场景下按以下策略选择域名：

1. **当前请求域名在配置列表中**：使用当前请求的域名，保持用户访问的域名一致性
2. **当前请求域名不在配置列表中**：使用配置中的第一个域名作为默认值

例如用户通过 `bbs.example.com` 访问时，`forum_url('t/1')` 生成 `https://bbs.example.com/t/1`，而非 `https://forum.example.com/t/1`，避免域名跳转导致 Session 丢失。

#### 性能影响

多域名拆分使用 `explode(',', $domainConfig)`，开销约 0.001ms，远小于已有的数据库查询开销（0.5-2ms），对整体性能无影响。

#### 部署要求

每个绑定的域名都需要完成 DNS 解析和 Web 服务器配置，详见 21.7 节。

### 21.9 注意事项

1. **模式互斥**：`access_mode` 只能为 `path` 或 `domain`，不可同时启用两种模式
2. **域名切换需清除缓存**：修改访问模式后需执行 `php artisan route:clear` 和 `php artisan config:clear`，或在后台设置页面自动清除
3. **API 路由前缀固定**：前端 API 路由（`api/{appId}`）不随访问模式变化，始终使用路径前缀
4. **配置值从数据库读取**：ServiceProvider 中读取配置使用 `ConfigItem` 模型直接查询数据库，不使用 `config()` 函数（应用配置存储在数据库中，`config()` 可能读到缓存旧值）
5. **未配置域名时不注册路由**：子域名模式下如果 `access_domain` 为空，ServiceProvider 应跳过前端路由注册，避免注册无效的域名路由
6. **视图链接使用辅助函数**：视图中所有指向应用内页面的链接必须使用辅助函数（如 `forum_url()`）生成，不可硬编码路径
7. **home_menus 路径适配**：`manifest.json` 中 `home_menus` 的 `path` 字段应使用路径前缀模式的路径（如 `/forum`），子域名模式下导航菜单由应用自带布局渲染
8. **多域名绑定**：`access_domain` 支持逗号分隔的多个域名（如 `forum.example.com,bbs.example.com`），每个域名都会注册完整路由。辅助函数优先使用当前请求匹配的域名，保持用户访问一致性。详见 21.8 节