Compare commits

...

8 Commits

Author SHA1 Message Date
tim
91b393a00c feat: 增加部署文档与导航入口 2026-02-11 19:58:59 +08:00
tim
658859a05a feat: AGENTS.md update 2026-02-11 19:52:53 +08:00
Tim
76dd57b858 Merge pull request #1139 from nagisa77/feature/fix-category-back-history
Feature/fix category back history
2026-02-06 17:44:55 +08:00
Tim
18edde64c3 fix: sync home filter dropdown state to URL history 2026-02-06 17:42:35 +08:00
Tim
ebc79f36e7 fix: keep browser history for home category and tag filters 2026-02-06 17:35:59 +08:00
Tim
a7fbd1eb75 Merge pull request #1138 from nagisa77/feature/fix-notification-mark-all-read
fix: mark all unread notifications across all pages
2026-02-06 17:19:57 +08:00
Tim
f773d17748 fix: mark all unread notifications across all pages 2026-02-06 17:16:02 +08:00
Tim
15d36709c3 Merge pull request #1137 from nagisa77/feature/agents-guidelines
chore: add AGENTS guides for root and submodules
2026-02-06 15:11:42 +08:00
13 changed files with 288 additions and 20 deletions

View File

@@ -66,3 +66,13 @@
- 不提交 `.env`、密钥、生产 token。
- 不在未明确授权下执行破坏性命令(如大范围删除、强制重置)。
- 不在无关文件中进行格式化/重排以“顺手优化”。
## 9) Agent 开发规范
- 开发前需先 `checkout` 新分支,并在该分支完成提交。
- 分支命名格式:
- 新功能:`feature/<简要描述>`
- 缺陷修复:`bugfix/<简要描述>`
- 提交信息格式:
- 新功能:`feat: <简要描述>`
- 缺陷修复:`bugfix: <简要描述>`

View File

@@ -19,4 +19,5 @@ bun dev
- `frontend/` 前端技术文档
- `backend/` 后端技术文档
- `deployment/` 预发/生产部署文档
- `openapi/` 后端 API 文档

View File

@@ -0,0 +1,147 @@
---
title: 部署指南
description: OpenIsle 预发与生产环境部署说明
---
# 部署指南
本页覆盖 OpenIsle 当前仓库内已有的部署链路:`deploy/` 脚本 + GitHub Actions 工作流 + Docker Compose。
## 部署总览
| 环境 | 触发方式 | 脚本 |
| --- | --- | --- |
| 预发 (staging) | `main` 分支 push / 手动触发 | `deploy/deploy_staging.sh` |
| 生产 (prod) | 每日定时 / 手动触发 | `deploy/deploy.sh` |
说明:
- 两套工作流共用并发锁 `openisle-server`,避免同一台服务器并发部署冲突。
- 两个脚本都会执行 `git checkout -B <branch> origin/<branch>` + `git reset --hard origin/<branch>`,确保服务器代码与远端分支对齐。
## 前置条件
1. 服务器已安装:`git`、`docker`、`docker compose`(插件版本)。
2. 服务器目录与脚本保持一致:
- 生产仓库路径:`/opt/openisle/OpenIsle`
- 预发仓库路径:`/opt/openisle/OpenIsle-staging`
3. 两个仓库目录下都已创建 `.env`(基于根目录 `.env.example`)。
4. 已配置反向代理(参考 `nginx/openisle` 与 `nginx/openisle-staging`)。
## 环境变量准备
先复制模板:
```bash
cp .env.example .env
```
至少确认这些变量:
- 安全相关:`JWT_SECRET`、`JWT_REASON_SECRET`、`JWT_RESET_SECRET`、`JWT_INVITE_SECRET`
- 存储与队列:`MYSQL_*`、`REDIS_*`、`RABBITMQ_*`
- 站点与前端:`WEBSITE_URL`、`NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL`、`NUXT_PUBLIC_WEBSITE_BASE_URL`
如果同机同时跑“生产 + 预发”,预发端口必须改开,避免冲突。根据当前 Nginx 示例,预发可使用:
```dotenv
SERVER_PORT=8081
FRONTEND_PORT=3001
WEBSOCKET_PORT=8083
OPENISLE_MCP_PORT=8086
WEBSITE_URL=https://staging.open-isle.com
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
NUXT_PUBLIC_WEBSOCKET_URL=https://staging.open-isle.com/websocket
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
```
## 手动部署
在服务器执行:
```bash
# 生产(默认 main
bash /opt/openisle/OpenIsle/deploy/deploy.sh
# 预发(默认 main
bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh
```
部署指定分支:
```bash
bash /opt/openisle/OpenIsle/deploy/deploy.sh feature/xxx
bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh feature/xxx
```
脚本会自动完成:
1. 拉取并重置代码到目标分支最新提交
2. `docker compose config` 校验
3. 拉取基础镜像 + 构建 `frontend_service`、`mcp`
4. 重建并启动关键服务(`mysql`、`redis`、`rabbitmq`、`websocket-service`、`springboot`、`frontend_service`、`mcp`
## CI/CD 触发规则
- 预发:`.github/workflows/deploy-staging.yml`
- `main` 分支 push 自动触发
- 支持 `workflow_dispatch` 手动触发
- 生产:`.github/workflows/deploy.yml`
- 每天 `UTC 19:00` 定时触发(北京时间次日 `03:00`
- 支持 `workflow_dispatch` 手动触发
- 文档站:`.github/workflows/deploy-docs.yml`
- 在预发部署成功后触发,发布到 `gh-pages`
## 部署后检查
查看容器状态:
```bash
docker compose -f /opt/openisle/OpenIsle/docker/docker-compose.yaml --env-file /opt/openisle/OpenIsle/.env ps
docker compose -f /opt/openisle/OpenIsle-staging/docker/docker-compose.yaml --env-file /opt/openisle/OpenIsle-staging/.env ps
```
查看核心服务日志:
```bash
docker compose -f /opt/openisle/OpenIsle/docker/docker-compose.yaml --env-file /opt/openisle/OpenIsle/.env logs -f springboot websocket-service frontend_service
```
本机健康检查(自动读取 `.env` 端口):
```bash
ENV_FILE=/opt/openisle/OpenIsle/.env
SERVER_PORT=$(grep '^SERVER_PORT=' "$ENV_FILE" | cut -d= -f2)
WS_PORT=$(grep '^WEBSOCKET_PORT=' "$ENV_FILE" | cut -d= -f2)
curl -fsS "http://127.0.0.1:${SERVER_PORT}/actuator/health"
curl -fsS "http://127.0.0.1:${WS_PORT}/actuator/health"
```
## 回滚建议
由于部署脚本总是对齐远端分支最新提交,回滚建议走“可追溯分支”:
1. 在本地创建回滚分支并推送:
```bash
git checkout -b rollback/2026-02-11 <稳定提交SHA>
git push origin rollback/2026-02-11
```
2. 在服务器按分支重新部署:
```bash
bash /opt/openisle/OpenIsle/deploy/deploy.sh rollback/2026-02-11
```
同理可用于预发:
```bash
bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh rollback/2026-02-11
```
## 风险提示
- 脚本使用 `up -d --force-recreate --remove-orphans`,目标服务会被重建,部署窗口内可能出现短时连接中断。
- `.env` 缺失时脚本会直接退出,不会继续部署。
- 生产与预发共机时,务必避免端口冲突并保持 Nginx upstream 端口一致。

View File

@@ -0,0 +1,3 @@
{
"root": true
}

View File

@@ -11,4 +11,5 @@ OpenIsle 是一个现代化的社区平台,提供完整的社交功能。
- [后端开发指南](/backend) - 了解后端架构和开发
- [前端开发指南](/frontend) - 了解前端技术栈和组件
- [部署指南](/deployment) - 了解预发/生产部署流程与回滚方法
- [API 文档](/openapi) - 查看完整的 API 接口文档

View File

@@ -1,3 +1,3 @@
{
"pages": ["index", "frontend", "backend", "openapi"]
"pages": ["index", "frontend", "backend", "deployment", "openapi"]
}

View File

@@ -21,7 +21,7 @@ const props = defineProps({
const gotoCategory = async () => {
if (!props.category) return
const value = encodeURIComponent(props.category.id ?? props.category.name)
await navigateTo({ path: '/', query: { category: value } }, { replace: true })
await navigateTo({ path: '/', query: { category: value } })
}
const isImageIcon = (icon) => {

View File

@@ -30,7 +30,7 @@ defineProps({
const gotoTag = async (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
await navigateTo({ path: '/', query: { tags: value } })
}
const isImageIcon = (icon) => {

View File

@@ -357,13 +357,13 @@ const isImageIcon = (icon) => {
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name)
navigateTo({ path: '/', query: { category: value } }, { replace: true })
navigateTo({ path: '/', query: { category: value } })
handleItemClick()
}
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name)
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
navigateTo({ path: '/', query: { tags: value } })
handleItemClick()
}
</script>

View File

@@ -170,9 +170,9 @@ watch(selected, (val) => {
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
}
} else if (opt.type === 'category') {
navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
navigateTo({ path: '/', query: { category: opt.id } })
} else if (opt.type === 'tag') {
navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
navigateTo({ path: '/', query: { tags: opt.id } })
}
selected.value = null
keyword.value = ''

View File

@@ -202,6 +202,28 @@ const selectedTagsSet = (tags) => {
.map((v) => (isNaN(v) ? v : Number(v)))
}
const normalizeCategoryFromQuery = (category) => {
if (category == null || category === '') return ''
const raw = Array.isArray(category) ? category[0] : category
const decoded = decodeURIComponent(raw)
return isNaN(decoded) ? decoded : Number(decoded)
}
const normalizeTagsFromQuery = (tags) => {
if (tags == null || tags === '') return []
const raw = Array.isArray(tags) ? tags.join(',') : tags
return raw
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
const arraysShallowEqual = (a = [], b = []) => {
if (a.length !== b.length) return false
return a.every((v, idx) => String(v) === String(b[idx]))
}
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
onMounted(() => {
const { category, tags } = route.query
@@ -239,6 +261,32 @@ watch(
},
)
// 从筛选器变更回写到 URL确保浏览器历史可回退到上一个筛选状态。
watch([selectedCategory, selectedTags], async ([category, tags]) => {
const routeCategory = normalizeCategoryFromQuery(route.query.category)
const routeTags = normalizeTagsFromQuery(route.query.tags)
const categoryChanged = String(category ?? '') !== String(routeCategory ?? '')
const tagsChanged = !arraysShallowEqual(tags || [], routeTags)
if (!categoryChanged && !tagsChanged) return
const nextQuery = { ...route.query }
if (category == null || category === '') {
delete nextQuery.category
} else {
nextQuery.category = encodeURIComponent(String(category))
}
if (!Array.isArray(tags) || tags.length === 0) {
delete nextQuery.tags
} else {
nextQuery.tags = tags.map((v) => encodeURIComponent(String(v))).join(',')
}
await navigateTo({ path: '/', query: nextQuery })
})
/** 选项加载(分类/标签名称回填) **/
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {

View File

@@ -643,7 +643,7 @@ const sendMessage = async () => {
const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name)
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
navigateTo({ path: '/', query: { tags: value } })
}
const init = async () => {

View File

@@ -82,6 +82,56 @@ export async function markNotificationsRead(ids) {
}
}
const MARK_ALL_FETCH_SIZE = 100
const MARK_ALL_CHUNK_SIZE = 200
const MARK_ALL_MAX_PAGES = 200
async function fetchUnreadNotificationsPage(page, size) {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) throw new Error('NO_TOKEN')
const res = await fetch(`${API_BASE_URL}/api/notifications/unread?page=${page}&size=${size}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!res.ok) throw new Error('FETCH_UNREAD_FAILED')
const data = await res.json()
return Array.isArray(data) ? data : []
}
async function collectUnreadNotificationIds(excludedTypes = []) {
const excludedTypeSet = new Set(excludedTypes)
const ids = []
for (let page = 0; page < MARK_ALL_MAX_PAGES; page++) {
const pageData = await fetchUnreadNotificationsPage(page, MARK_ALL_FETCH_SIZE)
if (pageData.length === 0) break
for (const notification of pageData) {
if (!notification || excludedTypeSet.has(notification.type)) continue
if (typeof notification.id !== 'number') continue
ids.push(notification.id)
}
if (pageData.length < MARK_ALL_FETCH_SIZE) break
}
return [...new Set(ids)]
}
async function markNotificationsReadInChunks(ids) {
for (let i = 0; i < ids.length; i += MARK_ALL_CHUNK_SIZE) {
const chunk = ids.slice(i, i + MARK_ALL_CHUNK_SIZE)
const ok = await markNotificationsRead(chunk)
if (!ok) return false
}
return true
}
export async function fetchNotificationPreferences() {
try {
const config = useRuntimeConfig()
@@ -390,29 +440,37 @@ function createFetchNotifications() {
}
const markAllRead = async () => {
// 除 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value
// 为了覆盖分页中的全部未读,先从后端分页拉取全部未读 ID除 REGISTER_REQUEST)。
const localIdsToMark = notifications.value
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
.map((n) => n.id)
if (idsToMark.length === 0) return
notifications.value.forEach((n) => {
if (n.type !== 'REGISTER_REQUEST') n.read = true
})
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
const ok = await markNotificationsRead(idsToMark)
if (!ok) {
try {
const idsToMark = await collectUnreadNotificationIds(['REGISTER_REQUEST'])
if (idsToMark.length > 0) {
const ok = await markNotificationsReadInChunks(idsToMark)
if (!ok) throw new Error('MARK_READ_FAILED')
}
await fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
} catch (e) {
notifications.value.forEach((n) => {
if (idsToMark.includes(n.id)) n.read = false
if (localIdsToMark.includes(n.id)) n.read = false
})
await fetchUnreadCount()
toast.error('已读操作失败,请稍后重试')
return
}
fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
}
return {
fetchNotifications,