# 银妆银饰志后台

本项目采用与官网同端口的 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:8790/`
- 文章后台：`http://localhost:8790/admin`
- 银饰志：`http://localhost:8790/articles/`

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

- `CMS_PORT`：官网与后台共用的服务端口，默认 `8790`（斯嘉丽项目端口 `8788` + 1）
- `CMS_PASSWORD`：后台登录密码
- `CMS_SECRET`：后台会话签名密钥
- `CMS_API_KEY`：外部程序调用管理 API 时使用的 Bearer Token
- `CMS_DATA_DIR`：生产环境的用户数据目录，建议设置为仓库外的绝对路径
- `CMS_BUILD_LOCK`：代码部署与文章发布共用的锁文件，生产环境各个 release 必须配置成同一路径

## 后台 API

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

| 分组 | 方法与地址 | 用途 |
| --- | --- | --- |
| 会话 | `GET /api/cms/session` | 查询登录状态 |
| 会话 | `POST /api/cms/session` | 登录后台 |
| 会话 | `DELETE /api/cms/session` | 退出登录 |
| 分类 | `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` | 上传文章图片 |
| 构建 | `GET /api/cms/build` | 查询静态构建状态 |

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

## 内容目录

- `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
```

CI/CD 应在新的 release 目录部署代码，不要在正在运行的目录执行 `git clean`。仓库中的 `.gitlab-ci.yml` 兼容 GitLab 11.7，分为幂等部署、配置 Nginx、验证三个阶段。创建 release、执行 `npm ci`、生产构建和切换都在同一个 deploy job 中完成，不依赖不同 job 之间保留 `node_modules`。同一 pipeline 重试时会重建缺失的依赖；如果该 release 已成功上线，则直接作为成功处理。最终生产构建、切换 current 软链接、重启 PM2 和健康检查在同一把共享锁内完成。应用健康检查通过后才复制 Nginx 配置、检查语法并 reload。健康检查失败时会自动切回上一版本。

```bash
node --env-file=/root/yinzhuang/shared/.env scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh /root/yinzhuang /root/yinzhuang/releases/<release> yinzhuang /usr/bin/pm2 /root/.pm2 1 http://127.0.0.1:8790/
```

### GitLab CI/CD 配置

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

- 部署根目录：`/root/yinzhuang`
- 持久化配置：`/root/yinzhuang/shared/.env`
- PM2 应用名：`yinzhuang`
- 服务端口：`8790`
- 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=8790
```

首次运行 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`。

多个 GitLab pipeline 即使同时进入部署阶段，也会由服务器共享锁依次执行；部署持锁时，文章后台不会排队受理发布操作，而会明确提示编辑人员稍后重试。

验证阶段会输出 PM2 当前运行的全部项目，并只保留当前版本和上一个成功版本。清理脚本不会删除其他 pipeline 尚未完成的 release；本 pipeline 失败产生的目录会在自己的验证阶段清理。即使失败目录已被清理，重试 deploy job 也会重新创建 release 和依赖。若 Runner 被强制终止导致验证阶段未执行，可能留下一个未完成目录，但不会影响线上版本，可在确认没有对应 pipeline 运行后手动删除。

普通本地构建或不包含 release 切换的 CI 可以直接运行 `npm run build`，它会自行取得同一把锁。这样两条流水线不会同时读取或覆盖文章数据，也不会同时替换静态网站。
