# 银妆银饰资讯后台

本项目采用与官网同端口的 Astro 文章后台：Markdown 文件负责内容存储，Astro 同时提供后台 API 和静态页面生成。文章列表、分类页和详情页全部预渲染为 HTML，便于 SEO 与 GEO 搜索。

## 本地启动

首次启动先创建本地配置：

```bash
cp .env.example .env
```

请至少替换其中的 `CMS_PASSWORD`、`CMS_SECRET` 和 `CMS_API_KEY`，不要在共享环境中使用示例占位值。

开发模式（Astro 默认端口，支持页面热更新与同端口后台）：

```bash
npm run dev
```

- 官网：终端显示的开发地址（通常为 `http://localhost:4321/`）
- 文章后台：在同一地址后添加 `/admin`（通常为 `http://localhost:4321/admin`）

生产模式预览：

```bash
npm run build
npm start
```

- 官网：`http://localhost:8791/`
- 文章后台：`http://localhost:8791/admin`
- 银饰资讯：`http://localhost:8791/articles/`

启动命令会自动读取项目根目录的 `.env`，其中包含：

- `CMS_PORT`：官网与后台共用的服务端口，默认 `8791`
- `CMS_PASSWORD`：后台主密码；用户在后台修改密码后，该密码仍可用于登录和找回（修改 `.env` 后需重启应用）
- `CMS_SECRET`：后台会话签名密钥
- `CMS_API_KEY`：外部程序调用管理 API 时使用的 Bearer Token
- `CMS_DATA_DIR`：生产环境的用户数据目录，建议设置为仓库外的绝对路径
- `CMS_BUILD_LOCK`：代码部署与文章发布共用的锁文件，生产环境必须放在 `shared/` 中

## 后台 API

所有管理接口统一使用 `/api/cms` 前缀：

| 分组 | 方法与地址 | 用途 |
| --- | --- | --- |
| 会话 | `GET /api/cms/session` | 查询登录状态 |
| 会话 | `POST /api/cms/session` | 登录后台 |
| 会话 | `DELETE /api/cms/session` | 退出登录 |
| 账号 | `PUT /api/cms/account/password` | 修改后台用户密码并注销现有会话 |
| 分类 | `GET /api/cms/categories` | 分类列表与文章数量 |
| 分类 | `POST /api/cms/categories` | 新建分类 |
| 分类 | `PATCH /api/cms/categories` | 重命名分类 |
| 分类 | `DELETE /api/cms/categories` | 删除分类并迁移文章 |
| 文章 | `GET /api/cms/articles` | 文章列表 |
| 文章 | `POST /api/cms/articles` | 新建草稿 |
| 文章 | `GET /api/cms/articles/:slug` | 读取文章 |
| 文章 | `PUT /api/cms/articles/:slug` | 保存文章 |
| 文章 | `DELETE /api/cms/articles/:slug` | 删除文章 |
| 文章 | `DELETE /api/cms/articles/:slug/draft` | 放弃未发布修改 |
| 发布 | `POST /api/cms/articles/:slug/publish` | 发布并生成静态页面 |
| 发布 | `POST /api/cms/articles/:slug/unpublish` | 下架并生成静态页面 |
| 工具 | `POST /api/cms/preview` | Markdown 预览 |
| 工具 | `POST /api/cms/uploads` | 上传文章图片 |
| 导入 | `POST /api/cms/imports/word` | 导入 `.docx` 为 Markdown 草稿并提取内嵌图片 |
| 构建 | `GET /api/cms/build` | 查询静态构建状态 |

除会话登录接口外，外部程序可通过 `Authorization: Bearer <CMS_API_KEY>` 调用这些地址。

后台文章列表的“导入文件”支持 `.md` 和 `.docx`：Markdown 的第一个一级标题会作为标题并从正文移除；Word 文件由下述接口转换并直接创建草稿。导入默认使用分类列表中的第一个分类，保存后可在编辑页调整。

### 通过 API 导入 Word

Word 导入接口接收不超过 20MB 的 `.docx` Data URL，不支持旧版 `.doc`。文档第一个一级标题会作为默认文章标题并从正文中移除；也可以显式传入 `title` 覆盖。内嵌的 PNG、JPG、WEBP、GIF 图片会自动保存到上传目录并转成 Markdown 图片链接。导入只创建草稿，不会自动发布；转换或建稿失败时会删除本次已经提取的图片。

```http
POST /api/cms/imports/word
Authorization: Bearer <CMS_API_KEY>
Content-Type: application/json
```

```json
{
  "fileName": "银饰文章.docx",
  "dataUrl": "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,...",
  "category": "佩戴养护",
  "title": "可选的自定义标题"
}
```

成功后返回自动生成的 `slug`、最终 `title`、`imageCount`、转换警告和 `new-draft` 草稿状态。确认内容后再调用 `POST /api/cms/articles/:slug/publish` 发布。

## 内容目录

- `src/content/articles/`：后台工作稿，包括草稿、待发布和已发布文章
- `src/content/published/`：Astro 实际构建的已发布文章
- `src/content/categories.json`：后台用户维护的文章分类
- `public/uploads/`：后台上传的文章图片

以上目录均属于运行时用户数据，已在 `.gitignore` 中排除，不随 Git 提交或 CI/CD 部署覆盖。部署时应由服务器持久化并单独备份。

后台始终维护两个版本：

- `articles/` 是当前编辑稿；新建文章和已上线文章的未发布修改都保存在这里。
- `published/` 是官网当前使用的线上稿；保存草稿不会改动它。
- 点击“发布到官网”后，编辑稿才会覆盖线上稿，并重新执行 `npm run build`。
- 点击“下架文章”后，线上稿会移除，编辑稿继续保留为未发布草稿。
- 点击“删除文章”会同时删除编辑稿和线上稿；若官网构建失败，系统会自动恢复删除前的版本。

文章状态分为“新建未发布”“修改未发布”和“已上线”。文章网址、摘要、作者及 SEO 时间信息均由程序自动生成，编辑人员只需填写标题、分类和正文。

## 内容构建与代码部署

### 用户提交文章

1. 保存草稿只写入 `articles/`，不影响官网。
2. 发布、下架、删除文章或修改已使用的分类时，后台先取得共享构建锁。
   如果代码部署或其他构建已持有锁，本次操作直接返回冲突且不会排队受理，编辑人员稍后重试即可，避免服务重启导致已受理任务丢失。
3. 系统备份用户内容，修改 `published/`，在临时目录执行 Astro 静态构建。
4. 构建成功后原子替换 `dist/`；构建失败则恢复文章和分类数据，旧的 `dist/` 保持不变。

### Git 代码部署

生产环境必须把用户数据放在仓库外，例如：

```env
CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
```

生产服务器只保留一个运行版本：

```text
/root/yinzhuang/
├── current/     # 唯一运行版本，必须是实体目录
└── shared/      # 环境配置、CMS 数据、上传文件和锁文件
```

仓库中的 `.gitlab-ci.yml` 兼容 GitLab 11.7，CI 仅有一个 `deploy` 阶段和一个 job。完整流程是：停止本项目 PM2 进程，清空 `current`，用 `git archive` 导出当前提交，关联 `shared/.env`，执行 `npm ci` 和生产构建，启动 PM2，进行本机健康检查，最后安装并验证 Nginx 配置。

部署全程持有 `shared/deploy.lock`，并发 pipeline 会直接失败而不会排队，避免旧 pipeline 随后覆盖新提交。生产构建另外使用 `CMS_BUILD_LOCK` 与后台内容构建互斥。PM2 命令只操作配置的 `APP_NAME`，不会执行 `delete all`、`restart all` 或 `pm2 kill`。

该部署方式会在依赖安装和构建期间短暂停机，不保存历史版本，也不支持版本切换或自动回滚。部署失败时应修复问题后重新运行 pipeline。

### GitLab CI/CD 配置

默认假设 GitLab Shell Runner 与生产服务位于同一台服务器，并使用以下路径：

- 部署根目录：`/root/yinzhuang`
- 持久化配置：`/root/yinzhuang/shared/.env`
- PM2 应用名：`yinzhuang`
- 服务端口：`8791`
- Nginx 配置：`/etc/nginx/conf.d/yinzhuang.conf`
- 公网入口：`101.126.10.129:80`

如果服务器实际路径或 PM2 应用名不同，只修改 `.gitlab-ci.yml` 顶部变量即可。脚本只会对 `APP_NAME` 指定的一个 PM2 进程执行 `describe`、`delete`、`start`，不会停止、重启或保存其他 PM2 项目。

服务器需提前创建 `shared/.env`，至少配置 `CMS_DATA_DIR`、`CMS_BUILD_LOCK`、`CMS_PASSWORD`、`CMS_SECRET`、`CMS_API_KEY` 和 `CMS_PORT`。其中建议使用：

```env
CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
CMS_PORT=8791
```

`CMS_DATA_DIR` 中包含工作稿、已发布文章、分类和上传文件。数据库文件、其他业务数据及所有运行期生成的持久化内容也必须放在 `shared/` 中，再通过环境变量或软链接提供给应用。`current` 的全部内容会在每次部署时删除，不能用于持久化数据。

### 从旧 releases 结构迁移

首次执行新流程时，CI 会先停止本项目 PM2 进程。如果 `current` 是软链接，只会执行 `unlink` 删除链接本身，并保留链接指向的旧 release；随后创建同名实体目录并部署。

旧的 `/root/yinzhuang/releases` 不在新部署脚本的操作范围内。确认不再需要回退且已完成必要备份后，由管理员人工删除；CI 不会读取、清理或修改该目录。

首次运行 pipeline 前，需要允许 Runner 执行参数固定的 Nginx 安装、检查和 reload 命令：

```bash
sudo visudo -f /etc/sudoers.d/yinzhuang-runner
```

`/etc/sudoers.d/yinzhuang-runner` 内容：

```sudoers
Cmnd_Alias YINZHUANG_NGINX = \
  /usr/bin/install -o root -g root -m 0644 /root/yinzhuang/current/deploy/nginx/yinzhuang.conf /etc/nginx/conf.d/yinzhuang.conf, \
  /usr/sbin/nginx -t, \
  /usr/bin/systemctl reload nginx

gitlab-runner ALL=(root) NOPASSWD: YINZHUANG_NGINX
```

保存后执行 `sudo visudo -cf /etc/sudoers.d/yinzhuang-runner` 检查语法。CI 只会从 `/root/yinzhuang/current` 读取本项目配置，并只会更新 `/etc/nginx/conf.d/yinzhuang.conf`。

普通本地构建可以直接运行 `npm run build`，它会自行取得同一把内容构建锁，避免多个构建同时读取或覆盖文章数据及静态网站。
