24 Commits

Author SHA1 Message Date
e7b0686947 chore: bump version to 0.2.10 2026-10-09 19:07:57 +08:00
da72843c70 feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等 2026-10-09 18:53:38 +08:00
5598e1cbb7 chore: bump version to 0.2.9 2026-10-03 02:40:45 +08:00
46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00
8cfdfcbbf0 chore: bump version to 0.2.8 2026-10-02 01:48:02 +08:00
11f2131e4b fix: 书库阅读页/帖子详情页/SiteDocMasthead/TOC 等前端细节修复 2026-10-02 01:47:14 +08:00
7912b67ddc chore: bump version to 0.2.7 2026-10-02 01:41:19 +08:00
99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00
3e98052c16 chore: bump version to 0.2.6 2026-10-01 03:08:23 +08:00
ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00
7fbd6ba699 chore: 发布 0.2.5 2026-09-30 16:37:52 +08:00
4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00
e487bc2e92 chore: 发布 0.2.4 2026-09-28 03:35:16 +08:00
3e0689fe98 fix: 管理会话自动恢复与访问来源统计优化
- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录
- 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配
- 访问来源统计查询优化与测试、管理端 analytics sources 页面适配
- 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
2026-09-28 03:33:52 +08:00
0c3b3a710c chore: 发布 0.2.3 2026-09-28 01:30:49 +08:00
34aee91a13 feat: 论坛 emoji 表情支持
- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件
- Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本
- 聊天室与首页适配
2026-09-28 01:28:41 +08:00
2f96a36275 chore: 发布 0.2.2 2026-09-27 04:42:46 +08:00
7d8c79f61f fix: 默认板块仅在空库播种,改名后重启不再复活 2026-09-27 04:42:46 +08:00
372bc29b41 fix: API 构建加 -tags nodynamic,避免 purego 动态链接致 Alpine 无法启动 2026-09-27 04:18:35 +08:00
023f17ed46 chore: 发布 0.2.1 2026-09-27 04:04:49 +08:00
7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00
1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00
734afbc39f Merge pull request 'fix: 主动登出后误弹「登录已失效」与游客埋点 403' (#1) from fix/logout-session-telemetry into main
Reviewed-on: #1
2026-09-25 20:57:43 +00:00
21174dc5cb fix: 主动登出后误弹「登录已失效」与游客埋点 403
- apiLogout 成功后复位 sessionSeen:主动登出不再被后续 401 误判为「被顶下线」,消除登出后登录页误弹 AccountGuard 弹窗,及其引发的二次 logout 清掉刚补发 CSRF cookie、首次登录报「CSRF token 缺失」的问题

- apiTelemetryPageView 无 CSRF cookie 时跳过上报:游客首屏(cookie 尚未由 GET 补发)不再产生必然失败的 403,cookie 补发后照常上报
2026-09-26 04:55:27 +08:00
3810 changed files with 37478 additions and 3685 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ out/
.vercel/
*.tsbuildinfo
next-debug.log*
# postbuild 抽取的字体声明(scripts/extract-font-css.mjs 生成)
frontend/public/fonts/
# ===== Go 构建产物 =====
*.exe

220
LICENSE
View File

@@ -1,38 +1,202 @@
姜十三论坛 专有软件许可协议
Proprietary Software License Agreement
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2026 姜十三. All Rights Reserved.
版权所有 (c) 2026 姜十三。保留所有权利。
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
本软件(包括但不限于源代码、目标代码、文档、图像及其他随附资料,
以下统称"本软件")由版权方(以下称"授权方")开发并所有。本软件
以源码可见的方式发布,但这并不构成任何形式的开源授权。
1. Definitions.
在未获得授权方事先书面许可之前,任何个人或组织不得:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
1. 以任何目的(包括商业、内部生产、教学培训等)使用、运行或部署
本软件的全部或任何部分;
2. 复制、影印、转载、镜像、收录到任何数据库或以任何方式再现本软件;
3. 修改、改编、翻译、合并、反向工程、反编译、反汇编本软件,或基于
本软件创作衍生作品;
4. 出售、出租、出借、分销、再许可、托管(SaaS)或以其他方式向第三方
提供本软件或其任何副本;
5. 删除、遮盖或更改本软件中的版权声明、商标或其他权利声明。
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
仅可出于个人学习、评估是否购买授权的目的浏览本仓库中的源代码;该等
浏览不视为获得任何明示或默示的使用许可。
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
商业使用、内部部署或其他任何形式的使用授权,均须与授权方另行签订
书面许可协议。
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
第三方开源组件按其各自附带的许可证条款授权,不受本协议约束。
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
本软件按"现状"(AS IS)提供,不附带任何明示或默示的担保,包括但不限
于适销性、特定用途适用性或不侵权的担保。在任何情况下,授权方均不对因
使用或无法使用本软件而导致的任何直接、间接、附带、特殊或后果性损害
承担责任。
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
未经授权使用本软件的行为,均构成对授权方著作权及其他知识产权的侵犯,
授权方保留依法追究法律责任的权利。
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
授权方联系方式:aarbbs@88.com
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that the copyright owner or their authorized
representative conspicuously designates in writing as "Not a
Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with the conditions
stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides their Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The boilerplate notice should be enclosed in
appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be
included on the same "printed page" as the copyright notice for
easier identification within third-party archives.
Copyright 2026 姜十三
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

422
README.md
View File

@@ -1,101 +1,128 @@
<div align="center">
# 姜十三论坛(jiang13-bbs)
一个基于 Go + Next.js 的现代社区论坛系统,采用 SSR 直出 + HttpOnly Cookie 认证方案,具备完整的帖子/评论/通知/签到/管理后台功能。
一个基于 **Go + Next.js** 构建的现代化社区论坛系统,SSR 直出、HttpOnly Cookie 认证,内置内容社区、代码片段、书库、积分体系、实时群聊与完整管理后台,开箱即用。
> **许可声明:** 本项目为专有软件(Proprietary),源码可见但不构成开源授权。详见 [LICENSE](./LICENSE)。未经书面许可,不得使用、部署或分发。
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE)
[![Version](https://img.shields.io/badge/version-0.2.9-green.svg)](./VERSION)
[![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![Next.js](https://img.shields.io/badge/Next.js-16-black?logo=nextdotjs&logoColor=white)](https://nextjs.org/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#贡献指南)
</div>
---
## 目录
- [项目截图](#项目截图)
- [功能特性](#功能特性)
- [技术栈](#技术栈)
- [快速开始](#快速开始)
- [配置说明](#配置说明)
- [Docker 部署](#docker-部署)
- [项目结构](#项目结构)
- [安全设计](#安全设计)
- [开发与测试](#开发与测试)
- [更新日志](#更新日志)
- [贡献指南](#贡献指南)
- [许可证](#许可证)
- [联系方式](#联系方式)
- [赞赏支持](#赞赏支持)
---
## 项目截图
| 首页信息流 | 帖子详情 |
| :---: | :---: |
| ![首页](docs/images/首页.png) | ![帖子详情](docs/images/帖子详情.png) |
| 代码片段广场 | 书库在线阅读 |
| ![代码片段](docs/images/代码片段.png) | ![书库](docs/images/书库.png) |
| 管理后台仪表盘 | 访问统计分析 |
| ![管理后台](docs/images/管理后台.png) | ![访问统计](docs/images/访问统计.png) |
<!--
后台设置类截图(邮件 / 过滤 / 维护 / 安全 / 存储,含移动端与暗色模式):
docs/screenshots/settings/
-->
---
## 功能特性
### 内容社区
- 帖子发布 / 编辑 / 删除,支持普通帖、**问答帖**(采纳答案、状态流转)、**投票帖**、**悬赏帖**、**抽奖帖**,以及密码帖与付费解锁内容
- Markdown 写作与渲染:Shiki 代码高亮、目录导航、图片灯箱、视频/附件
- 楼层化评论:主评论占楼层、子评论不占楼层,最多 10 层嵌套,单次查询拼装评论树,无 N+1
- 评论与帖子均提供**编辑历史**、软删除与回收站(恢复 / 彻底删除)
- 点赞、收藏、关注、置顶、推荐、锁定,联合唯一索引防止重复互动
- 附件可设定积分价格,支持付费下载
### 积分与成长体系
- 每日签到 + 连续签到加成,积分余额与完整流水台账
- 用户等级体系(经验值自动升级)与成就徽章(手动授予 / 自动通知)
- 全站积分排行榜与个人排名
### 代码片段
- 独立的代码片段广场:多语言语法高亮、按语言筛选、全站统计
- Fork、一键复制计数、片段评论、版本历史、可见性管理
### 书库 / 文档中心
- 书籍文档管理,两级章节(章 → 节)结构
- 支持 **EPUB 解析导入**、Markdown 单篇 / 批量导入、整库导出与单本导出
- 内置在线阅读器,封面、附件下载一应俱全
### 私信与群聊
- 一对一私信、多人群聊房间、默认全站大厅
- 群成员管理:邀请 / 移出、禁言、角色设置;消息撤回、未读汇总、会话置顶
### 实时通知
- 评论、回复、点赞、@、系统通知等多类型站内消息
- 未读红点 **SSR 首屏直出**,WebSocket 实时推送,页面回到前台时 HTTP 校准,支持单条 / 全部已读
### 管理后台
- 仪表盘与分析概览(Recharts 图表)
- 内容审核队列(待审帖子 / 评论,板块管理员仅见授权板块)与内容回收站
- 板块管理、版主授权、排序
- 用户管理:角色、细粒度权限码、封禁、消息权限、登录日志、登录设备、积分调整
- 公告文章、站点单页(CMS)管理
- 广告位与赞助商配置(含用户自助购买提交、二维码、验证码)
- 友情链接:用户申请、后台审批
- 侧边栏可视化布局配置 + 自定义 HTML 小部件
- 背景图、品牌 Logo 上传裁剪;全站媒体库盘点
- **访问统计**:概览、实时、来源、页面、访客、安全、事件等多维度分析
- 旧站数据导入(WordPress / Typecho 等)
- 更新时间线:从 Git Commits / Releases 自动生成更新日志
- 维护模式、系统诊断、SMTP 邮件配置(凭据加密存储)
- 支持 S3 / MinIO 对象存储:文件内容去重、自适应图片变体、WebP 自动转换
### SEO
- 全站 SSR / ISR,title、description、OG 等关键内容均在初始 HTML 中
- 帖子详情页输出 `DiscussionForumPosting` 类型 JSON-LD
- 动态 `sitemap`、`robots`、Canonical 链接
---
## 技术栈
| 层级 | 技术 |
|------|------|
| 层级 | 技术选型 |
|------|----------|
| 后端 | Go 1.27 · Gin · GORM · PostgreSQL 17 |
| 前端 | Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 |
| 认证 | JWT (HttpOnly Cookie) + Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 |
| 部署 | 官方:`deploy/` Docker Compose(Next SSR + Go + Postgres,反代自备);本地开发仅 Compose 起数据库 |
---
## 功能概览
### 用户
- 注册 / 登录 / 登出(注册即登录,免二次跳转)
- 个人资料编辑:昵称、头像、邮箱、个性签名
- 修改密码(改密后全端踢下线)
- 个人主页:发帖列表、评论列表(分页)、签到积分
- 每日签到(积分 + 连续签到天数)
### 内容
- 帖子发帖 / 编辑 / 删除(作者本人或管理员)
- 帖子类型:普通帖 / 问答帖
- 帖子置顶 / 推荐(仅管理员)
- 评论楼层制:主评论 = 楼层,子评论不占楼层,最多 10 层嵌套
- 楼层分页 + 内存拼装评论树(单次查询,无 N+1)
- Markdown 渲染(代码高亮、引用、列表等)
- 帖子点赞(联合唯一索引防重复)
### 通知
- 站内通知:评论你的帖子、回复你的评论、点赞你的帖子
- 未读红点 SSR 直出,实时靠 WebSocket 推送,回前台时 HTTP 校准
- 单条标记已读 / 全部已读
### 管理后台
- 公告管理(草稿 / 发布、标签预设色)
- 仪表盘
### SEO
- 全站 SSR/ISR,SEO 关键内容在初始 HTML 中
- 帖子详情页输出 `DiscussionForumPosting` 类型 JSON-LD
- 动态 `sitemap.ts` + `robots.ts`(由 Next.js 对外提供,后端不重复挂 `/sitemap.xml` / `/robots.txt`)
### 安全
- CSP / HSTS / X-Frame-Options: DENY / X-Content-Type-Options / Referrer-Policy
- 速率限制:登录 20/min、注册 10/min、发帖新用户 24h 冷静期
- Refresh Token 一次性轮转 + 盗用检测(吊销后重放 → 杀全家族)
- 生产 HTTPS 启用 `__Host-` Cookie 前缀
---
## 项目结构
```
jiang13-bbs/
├── backend/ # Go 后端
│ ├── cmd/jiang13/ # 入口 main.go
│ ├── config/ # 配置解析(app.ini + 环境变量)
│ ├── handler/ # HTTP 处理器
│ ├── middleware/ # 认证 / CSRF / 限流 / 安全头
│ ├── model/ # GORM 模型 + 数据库初始化
│ ├── router/ # 路由注册
│ ├── service/ # 业务逻辑层
│ ├── app.ini.example # 配置模板
│ └── go.mod
├── frontend/ # Next.js 前端
│ ├── app/ # App Router 页面
│ │ ├── settings/ # 账号设置(资料 + 密码)
│ │ ├── u/[id]/ # 用户主页
│ │ ├── post/[id]/ # 帖子详情
│ │ ├── compose/ # 发帖
│ │ ├── notifications/ # 通知列表
│ │ ├── admin/ # 管理后台
│ │ └── ...
│ ├── components/ # 复用组件
│ ├── lib/ # API 封装 / 工具函数
│ ├── middleware.ts # SSR 登录态静默轮转
│ ├── next.config.ts # Rewrite 代理 / 图片缓存
│ └── .env.example
├── deploy/ # 官方运行时(web + api + Postgres,反代自备)
├── docker-compose.yml # 仅本地开发 Postgres
├── LICENSE # 专有软件许可协议
└── .gitignore
```
| 前端 | Next.js 16(App Router)· React 19 · TypeScript · Tailwind CSS v4 |
| 认证 | JWT(HttpOnly Cookie)+ Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 |
| 实时 | WebSocket(gorilla/websocket) |
| 存储 | 本地存储 / S3(MinIO),Markdown 引擎 Goldmark,代码高亮 Shiki |
| 部署 | Docker Compose:Next SSR + Go API + PostgreSQL(反向代理自备) |
---
@@ -104,8 +131,8 @@ jiang13-bbs/
### 前置条件
- Go 1.27+
- Node.js 20+ / pnpm 或 npm
- PostgreSQL 17(或通过 Docker Compose 启动)
- Node.js 20+(npm)
- PostgreSQL 17(可通过 Docker Compose 启动)
### 1. 启动数据库
@@ -123,9 +150,9 @@ go mod download
go run ./cmd/jiang13
```
**工作目录必须是 `backend/`。** 上传文件与 JWT 密钥落在 `backend/data/`(头像 `backend/data/uploads/avatars/`)。不要在仓库根或 `backend/cmd/jiang13/` 执行 `go run`,否则会另建一套 data,数据库里头像 URL 会对不上文件。启动日志应含 `workPath=.../backend` 和 `dataDir=.../backend/data`。需要覆盖时设置 `JIANG13_WORK_PATH` 指向 `backend/`。
> **工作目录必须是 `backend/`。** 上传文件与 JWT 密钥落在 `backend/data/`(头像位于 `backend/data/uploads/avatars/`)。在仓库根或 `backend/cmd/jiang13/` 下执行会另建一套 data,导致数据库中的文件 URL 对不上。启动日志应包含 `workPath=.../backend`;需要覆盖时可设置 `JIANG13_WORK_PATH`。
后端默认监听 `:3001`,首次启动自动执行数据库迁移并写入默认板块。同一时间只跑一个后端进程。
后端默认监听 `:3001`,首次启动自动执行数据库迁移并写入默认板块。
### 3. 启动前端
@@ -136,13 +163,17 @@ npm install
npm run dev
```
前端默认监听 `:3000`,通过 `next.config.ts` rewrite 将 `/api/*` 代理到后端。
前端默认监听 `:3000`,通过 `next.config.ts` 的 rewrite 将 `/api/*` 代理到后端。
**空数据库首次注册的账号将自动成为站长。**
> Windows 用户也可直接使用仓库根目录的 `start.bat`(或 `start.sh`)一键启动开发环境,停止使用 `stop.bat` / `stop.sh`。
---
## 配置说明
### 后端(`backend/app.ini`)
### 后端 `backend/app.ini`
```ini
[server]
@@ -152,108 +183,189 @@ HTTP_PORT = 3001
DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
[security]
; 留空则自动生成并持久化到 data/.jwt_secret
; 生产环境务必显式指定强随机值
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
JWT_SECRET =
; 32 字节随机值的标准 Base64(openssl rand -base64 32),用于加密邮件 / S3 凭据
SETTINGS_MASTER_KEY =
[paths]
DATA = data
[app]
DEV_MODE = true
SITE_URL = http://localhost:3000
CORS_ORIGINS =
```
环境变量优先级高于 app.ini:`HTTP_PORT`、`DB_DSN`、`JWT_SECRET`、`DEV_MODE`。`JIANG13_WORK_PATH` 可强制指定后端根目录(`app.ini` 与 `data/` 所在处)。
环境变量优先级高于 `app.ini`:`HTTP_PORT`、`DB_DSN`、`JWT_SECRET`、`SETTINGS_MASTER_KEY`、`DEV_MODE`、`SITE_URL`、`CORS_ORIGINS`、`TRUSTED_PROXIES`。
### 前端(`frontend/.env.local`)
### 前端 `frontend/.env.local`
```bash
# SSR 与 middleware 直连后端(本地开发)
# SSR 与 middleware 直连后端
NEXT_PUBLIC_API_URL=http://localhost:3001
# 部署到 Cloudflare Workers 等边缘环境时必须显式配置
# 仅服务端可见;生产构建必须显式配置
# BACKEND_URL=https://your-backend.example.com
# 与后端 DEV_MODE 对齐
DEV_MODE=true
```
完整环境变量清单见 [.env.example](./.env.example)、[frontend/.env.example](./frontend/.env.example)。
---
## Docker 部署
官方运行时位于 [`deploy/`](./deploy/),一体交付 **Next.js SSR + Go API + PostgreSQL 17**,不含反向代理(TLS 与同源路由由你的 Nginx / Caddy 处理)。
```bash
cd deploy
cp .env.example .env
# 编辑 .env:POSTGRES_PASSWORD、JWT_SECRET、SITE_URL
docker compose up -d --build
```
然后将同一 Host 反向代理到容器:
- `/` → web `:3000`
- `/api/`(含 `/api/ws`)、`/uploads/`、`/health` → api `:3001`
`GET https://你的域名/health` 返回 `{"status":"ok"}` 即部署成功。
详细说明、升级与备份命令见 [deploy/README.md](./deploy/README.md),Nginx 配置示例见 [deploy/nginx.example.conf](./deploy/nginx.example.conf),运行时契约见 [deploy/CONTRACT.md](./deploy/CONTRACT.md)。1Panel 面板部署可参考 [docs/deploy-1panel.md](./docs/deploy-1panel.md)。
---
## 项目结构
```
jiang13-bbs/
├── backend/ # Go 后端
│ ├── cmd/jiang13/ # 程序入口
│ ├── config/ # 配置解析(app.ini + 环境变量)
│ ├── handler/ # HTTP 处理器
│ ├── middleware/ # 认证 / CSRF / 限流 / 安全头 / 访问捕获
│ ├── model/ # GORM 模型与数据库初始化
│ ├── realtime/ # WebSocket Hub
│ ├── router/ # 路由注册
│ ├── service/ # 业务逻辑层
│ ├── markdown/ # Markdown 扩展(隐藏内容等)
│ └── app.ini.example
├── frontend/ # Next.js 前端
│ ├── app/ # App Router 页面(含 admin 后台)
│ ├── components/ # 通用组件
│ ├── hooks/ # 自定义 Hooks
│ ├── lib/ # API 封装与工具函数
│ ├── public/ # 静态资源(表情图等)
│ ├── middleware.ts # SSR 登录态静默轮转
│ └── next.config.ts # Rewrite 代理 / 图片缓存
├── deploy/ # 官方运行时(Docker Compose / Dockerfile)
├── docs/ # 文档与截图
├── docker-compose.yml # 本地开发用(仅 PostgreSQL)
├── start.bat / start.sh # 本地一键启动
├── VERSION
└── LICENSE
```
---
## 认证架构
## 安全设计
采用三 Cookie + Token Version 方案,不使用 localStorage 存储 token:
采用三 Cookie 方案,令牌不落 localStorage:
| Cookie | 用途 | 属性 |
|--------|------|------|
| `j13_token` | Access JWT(15 分钟) | HttpOnly · SameSite=Lax |
| `j13_refresh` | Refresh Token(7 天,DB 存储 SHA-256) | HttpOnly · SameSite=Lax · Path=/ |
| `j13_refresh` | Refresh Token(7 天,DB 存 SHA-256) | HttpOnly · SameSite=Lax · Path=/ |
| `j13_csrf` | CSRF Token(JS 可读) | SameSite=Lax |
- **SSR 直出登录态:** `middleware.ts` 在页面/RSC 请求前本地解码 access token 的 exp,过期时静默调后端 refresh 轮转,新 cookie 注入请求头供 SSR 使用并透传浏览器。
- **客户端续期:** `lib/api.ts` 的 `fetchWithRefresh` 遇到 401 自动调 `/api/auth/refresh` 重试。
- **撤销机制:** 改密码 / 封禁 / 管理员强制下线时递增 `User.TokenVersion`,所有旧 JWT 立即失效。
- **盗用检测:** Refresh Token 一次性轮转,已吊销 token 重放时撤销该用户全部 refresh token。
生产 HTTPS 下 cookie 名自动启用 `__Host-` 前缀。
若本机曾跑过早期构建,浏览器里可能还留着 `Path=/api/auth` 的 `j13_refresh`(HttpOnly,页面脚本删不掉)。打开开发者工具 → Application → Cookies,删掉该条,或对该源执行「清除站点数据」。之后只应存在 `Path=/` 的三枚 cookie。
- **SSR 直出登录态**:`middleware.ts` 在渲染前检查 Access Token 过期时间,过期则静默轮转,新 Cookie 同时供 SSR 使用并透传浏览器
- **Refresh Token 一次性轮转 + 盗用检测**:已吊销 Token 被重放时,撤销该用户全部 Refresh Token
- **Token Version 撤销**:改密 / 封禁 / 强制下线时递增版本号,旧 JWT 立即失效
- **速率限制**:登录、注册、发帖(新用户 24h 冷静期)、评论、上传等分级限流,管理员豁免
- **安全响应头**:CSP、HSTS、X-Frame-Options: DENY、X-Content-Type-Options、Referrer-Policy
- 生产 HTTPS 下 Cookie 自动启用 `__Host-` 前缀,要求前后端必须**同源部署**(不能拆分到子域)
---
## 部署
**官方运行时是 `deploy/docker-compose.yml`(Next SSR + Go + Postgres,不含反代)。** 说明与升级命令见 [deploy/README.md](./deploy/README.md),Nginx 示例见 [deploy/nginx.example.conf](./deploy/nginx.example.conf),契约见 [deploy/CONTRACT.md](./deploy/CONTRACT.md)。
本地开发仍用仓库根 `start.bat` / `start.sh` 与根目录 `docker-compose.yml`(只起 Postgres)。不要把开发流程改成必须打应用镜像。
更新应用(镜像已发布时):
## 开发与测试
```bash
cd deploy
docker compose pull
docker compose up -d
# 后端测试
cd backend && go test ./...
# 前端测试(Node 内置 test runner)
cd frontend && npm test
# 前端代码检查
npm run lint
```
后台一键更新、预编译包不在本期。Cloudflare Workers 不是官方运行时。
开发约定:
### 生产环境要点
1. **官方 Compose:** `DEV_MODE=false`,强随机 `JWT_SECRET` 与 `POSTGRES_PASSWORD`;由你现有的 Nginx/Caddy 提供 HTTPS,并把页面与 `/api`、`/api/ws`、`/uploads` 挂到同一 Host。
2. **CORS 与 Canonical:** 设 `SITE_URL` 为对外 origin(不要改 `router.go` 写死域名)。官方 Compose 会同时注入 api 与 web;web 用它生成 Canonical / sitemap,不要填 `http://api:3001`。
3. **自管部署:** 仍须同源反代。Workers 前端须保持 `middleware.ts` 文件名(OpenNext 不识别 `proxy.ts`),实时通道由反代承接 `/api/ws`。
### WebSocket(开发 vs 生产)
浏览器发起 WebSocket 握手**一定会带 `Origin`**。后端拒绝空 Origin,这只挡住 curl / 脚本探测,**不影响 `npm run dev`,也不影响生产浏览器**。
**开发环境(`DEV_MODE=true`)**
- 页面在 `http://localhost:3000`,WS 直连 `ws://localhost:3001/api/ws`(Next.js rewrite 不透传 `Upgrade`)。
- 二者端口不同,不是浏览器意义上的同源。后端因此放行 Origin 主机为 `localhost` / `127.0.0.1` 的**任意端口**。
- Cookie 不按端口隔离,`:3000` 上的 `j13_token` 会随握手带到 `:3001`。不要把 `DEV_MODE` 改成 `false` 再跑这对端口,否则 Origin `localhost:3000` 对不上 Host `localhost:3001`,握手会被拒。
**生产环境(`DEV_MODE=false`)**
- Origin 的 host(含端口)必须与请求 `Host` **完全一致**。请用 Nginx/Caddy 把前端与 `/api/`、`/api/ws` 挂到同一域名,例如 `https://bbs.example.com`。
- 反向代理需透传 `Upgrade`、`Connection`,并保留原始 `Host` / `Origin`。
- 生产 cookie 使用 `__Host-` 前缀,要求 `Secure`、`Path=/`、**不能设 Domain**。因此 API **不能**放到 `api.example.com` 这类另一子域,否则登录 cookie 与 WS 都会失效。
- Cloudflare Workers / OpenNext **不能**透传任意 WebSocket。若前端部署在 Workers,实时通道必须由同源反代(Nginx 等)承接 `/api/ws`,而不是指望 Workers 升级连接。
- 可用 `NEXT_PUBLIC_WS_URL` 覆盖前端连线地址;一旦覆盖,Origin 仍须与后端看到的 Host 一致。
- Next.js 16 中 `params` / `searchParams` / `cookies()` / `headers()` 均需 `await`
- 权限校验只在 Go 后端执行,前端不做判定
- 配色仅修改 `frontend/app/globals.css` 中的 CSS 令牌,不在组件内硬编码颜色
- 所有公开页面保持 SSR / ISR,SEO 关键内容必须在初始 HTML 中
---
## 开发约定
## 更新日志
- **Next.js 16:** `params` / `searchParams` / `cookies()` / `headers()` 必须 `await`。
- **SEO:** 所有公开页面 SSR/ISR,SEO 关键内容(title、description、OG、JSON-LD)必须在初始 HTML 中。
- **权限:** 权限校验只在 Go 后端执行,前端不做判定。
- **配色:** 仅修改 `frontend/app/globals.css` 中的 CSS 令牌,禁止在组件内硬编码颜色。
- **无障碍:** 文字对比度 ≥4.5:1,交互控件边界 ≥3:1(WCAG 2.1 AA)。
版本发布与部署契约变更记录见 [deploy/CHANGELOG.md](./deploy/CHANGELOG.md),当前版本 **v0.2.9**。
---
## 贡献指南
欢迎提交 Issue 与 Pull Request!
1. Fork 本仓库并创建特性分支:`git checkout -b feature/your-feature`
2. 提交前请确保测试通过:后端 `go test ./...`,前端 `npm test`
3. 提交代码并推送分支
4. 在 GitHub 上发起 Pull Request,描述清楚改动内容与动机
- 提交信息建议遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范(`feat:` / `fix:` / `docs:` 等)
- 发现安全问题请**不要**直接提 Issue,发送邮件至 aarbbs@88.com 私下反馈
---
## 许可证
**专有软件(Proprietary)** — Copyright © 2026 姜十三,保留所有权利。
本项目基于 [Apache License 2.0](./LICENSE) 开源。
源码仅可见,未经书面许可不得使用、部署、修改或分发。详见 [LICENSE](./LICENSE)。
```
Copyright 2026 姜十三
联系方式:aarbbs@88.com
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
```
---
## 联系方式
- QQ:97975089
- 邮箱:97975089@qq.com
- Issue:[GitHub Issues](https://github.com/aarbbs/jiang13-bbs/issues)
---
## 赞赏支持
如果本项目对你有帮助,欢迎请作者喝杯咖啡 ☕,你的支持是持续维护的动力。
<p align="center">
<img src="docs/images/wechat.png" width="220" alt="微信打赏" />
&nbsp;&nbsp;&nbsp;&nbsp;
<img src="docs/images/zfb.png" width="220" alt="支付宝打赏" />
</p>
<p align="center">微信打赏 &nbsp;&nbsp;|&nbsp;&nbsp; 支付宝打赏</p>

View File

@@ -1 +1 @@
0.2.0
0.2.10

View File

@@ -257,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
func TestParseSiteURLFromIni(t *testing.T) {
work := t.TempDir()
body := "[security]\nJWT_SECRET = "+testJWTSecret+"\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
body := "[security]\nJWT_SECRET = " + testJWTSecret + "\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
t.Fatal(err)
}

View File

@@ -3,14 +3,18 @@ module github.com/freefire/jiang13-bbs
go 1.27.0
require (
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/gen2brain/webp v0.6.4
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
github.com/minio/minio-go/v7 v7.0.83
github.com/yuin/goldmark v1.7.8
golang.org/x/crypto v0.43.0
golang.org/x/image v0.46.0
golang.org/x/net v0.45.0
golang.org/x/text v0.42.0
gopkg.in/ini.v1 v1.67.0
gorm.io/driver/postgres v1.5.9
@@ -18,7 +22,6 @@ require (
)
require (
github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
@@ -26,10 +29,10 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -57,7 +60,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect

View File

@@ -18,8 +18,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -47,6 +51,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -104,8 +110,10 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -194,8 +202,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -205,6 +213,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -2,11 +2,13 @@ package handler
import (
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
@@ -16,10 +18,16 @@ import (
// ===== 数据导入(settingsAPI 组内,PermSettings 门控:仅超管/站长) =====
//
// POST /api/admin/import/:source multipart:
// - db_file 旧站 SQLite 库(必填)
// - avatars_zip 头像压缩包(可选)
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
// - dry_run "true"/"false",预检模式不写库(默认 false)
// - db_file 旧站 SQLite 库(必填)
// - avatars_zip 头像压缩包(可选)
// - images_zip 帖子图片压缩包(可选,旧站 data/uploads/posts 打包)
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
// - dry_run "true"/"false",预检模式不写库并返回勾选清单(默认 false)
// - skip_users 逗号分隔旧用户ID,不创建账号(内容仍按归属规则落位)
// - operator_users 逗号分隔旧用户ID,内容归到站长
// - user_map "旧用户ID:本站用户名" 逗号分隔,内容归到指定已有账号
// - skip_posts 逗号分隔旧帖ID,排除(其评论自动跳过)
// - skip_comments 逗号分隔旧评论ID,排除
//
// source 走白名单(当前仅 jiang13),为 WordPress / Typecho 等来源预留扩展。
@@ -32,7 +40,7 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
}
const overhead = 64 << 10 // multipart 边界与表单开销
limit := int64(service.LegacyDBMaxBytes) + int64(service.LegacyZipMaxBytes) + overhead
limit := int64(service.LegacyDBMaxBytes) + 2*int64(service.LegacyZipMaxBytes) + overhead
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
claims := middleware.CurrentUser(c)
@@ -50,25 +58,25 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "数据库文件不能超过 64MB"})
return
}
zipPath := ""
if zipFH, ferr := c.FormFile("avatars_zip"); ferr == nil && zipFH != nil && zipFH.Size > 0 {
if zipFH.Size > service.LegacyZipMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "头像压缩包不能超过 128MB"})
return
}
zipPath, err = saveMultipartToTemp(zipFH, "legacy-avatars-*.zip")
if err != nil {
respondLegacyUploadError(c, err, "读取头像压缩包失败")
return
}
zipPath, ok := optionalLegacyZip(c, "avatars_zip", "legacy-avatars-*.zip", "头像压缩包")
if !ok {
return
}
if zipPath != "" {
defer os.Remove(zipPath)
} else if ferr != nil {
// 表单整体解析失败(超限等);「字段不存在」直接忽略
var maxErr *http.MaxBytesError
if errors.As(ferr, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
return
}
}
imagesZipPath, ok := optionalLegacyZip(c, "images_zip", "legacy-images-*.zip", "帖子图片压缩包")
if !ok {
return
}
if imagesZipPath != "" {
defer os.Remove(imagesZipPath)
}
userMap, err := parseUserTargetMap(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dbPath, err := saveMultipartToTemp(dbFH, "legacy-*.db")
@@ -78,12 +86,17 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
}
defer os.Remove(dbPath)
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, service.LegacyImportOptions{
WithContent: formBool(c, "with_content", true),
DryRun: formBool(c, "dry_run", false),
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, imagesZipPath, service.LegacyImportOptions{
WithContent: formBool(c, "with_content", true),
DryRun: formBool(c, "dry_run", false),
SkipUserIDs: parseIDSet(c, "skip_users"),
OperatorUsers: parseIDSet(c, "operator_users"),
UserTargetNames: userMap,
SkipPostIDs: parseIDSet(c, "skip_posts"),
SkipCommentIDs: parseIDSet(c, "skip_comments"),
}, claims.ID)
if err != nil {
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) {
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) || errors.Is(err, service.ErrLegacyBadTarget) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -93,6 +106,75 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"report": rep})
}
// optionalLegacyZip 读取可选 zip 表单项并落临时文件;字段不存在返回空串。
// 出错时已写响应,返回 false 中止请求。
func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bool) {
fh, err := c.FormFile(field)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
return "", false
}
return "", true // 字段不存在,视为未上传
}
if fh.Size > service.LegacyZipMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label + "不能超过 128MB"})
return "", false
}
path, err := saveMultipartToTemp(fh, pattern)
if err != nil {
respondLegacyUploadError(c, err, "读取"+label+"失败")
return "", false
}
return path, true
}
// parseIDSet 解析逗号分隔的旧对象 ID 列表(如 "3,7,12")
func parseIDSet(c *gin.Context, key string) map[uint]bool {
v := strings.TrimSpace(c.PostForm(key))
if v == "" {
return nil
}
out := map[uint]bool{}
for _, part := range strings.Split(v, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
if err == nil && id > 0 {
out[uint(id)] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
// parseUserTargetMap 解析 "旧用户ID:本站用户名" 列表(如 "5:bob,7:alice");
// 目标账号由 service 在导入时校验并解析为账号 ID。
func parseUserTargetMap(c *gin.Context) (map[uint]string, error) {
v := strings.TrimSpace(c.PostForm("user_map"))
if v == "" {
return nil, nil
}
out := map[uint]string{}
for _, pair := range strings.Split(v, ",") {
kv := strings.SplitN(strings.TrimSpace(pair), ":", 2)
if len(kv) != 2 {
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
}
oldID, err := strconv.ParseUint(strings.TrimSpace(kv[0]), 10, 64)
name := strings.TrimSpace(kv[1])
if err != nil || oldID == 0 || name == "" {
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
}
out[uint(oldID)] = name
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func formBool(c *gin.Context, key string, def bool) bool {
v := c.PostForm(key)
if v == "" {

View File

@@ -94,6 +94,41 @@ func (h *Handlers) AdminSetUserMessages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": user})
}
// AdminGetUserPerms 读取账号级渠道权限(超管/站长,授权弹窗回显用)
func (h *Handlers) AdminGetUserPerms(c *gin.Context) {
id, ok := parseAdminUserID(c)
if !ok {
return
}
perms, err := h.AdminUser.GetPermOverrides(middleware.CurrentActor(c), id)
if err != nil {
respondAdminUserError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"perm_overrides": perms})
}
// AdminSetUserPerms 授予/撤销账号级渠道权限(超管/站长)
func (h *Handlers) AdminSetUserPerms(c *gin.Context) {
id, ok := parseAdminUserID(c)
if !ok {
return
}
var body struct {
Perms []string `json:"perms"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
user, err := h.AdminUser.SetPermOverrides(middleware.CurrentActor(c), id, body.Perms)
if err != nil {
respondAdminUserError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"user": user})
}
// AdminSetUserBan 封禁 / 解封用户
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
id, ok := parseAdminUserID(c)

View File

@@ -15,15 +15,15 @@ import (
)
var (
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 7 天
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 30 天
)
// setAuthCookies 设置认证 cookie:
// - access token(HttpOnly,15min)
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
// - access token(HttpOnly,7天)
// - refresh token(HttpOnly,30天,Path=/,供 Next middleware 在页面/RSC
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
// - CSRF token(JS 可读,7天,双提交校验)
// - CSRF token(JS 可读,与 refresh 同寿命(30天),双提交校验)
//
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
@@ -213,8 +213,9 @@ func (h *Handlers) Login(c *gin.Context) {
// dev 模式不设 Secure,生产环境需 HTTPS
h.Ops.ClearFailure(req.Username)
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
// 真·单会话:本次登录顶掉了所有旧会话,即时广播告知被踢设备(旧浏览器 WS 在线则
// 当场弹"登录已失效",无需等 access token 过期或硬刷新才发现)
// 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧
// 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等
// access token 过期或硬刷新才发现)
if kicked > 0 {
h.Hub.BroadcastUser(user.ID, realtime.Envelope{
Type: realtime.EventSessionReplaced,
@@ -241,13 +242,21 @@ func (h *Handlers) Refresh(c *gin.Context) {
}
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
clearAuthCookies(c, !h.Cfg.DevMode)
if errors.Is(err, service.ErrAccountBanned) {
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
// 其他错误(数据库抖动等):服务暂不可用。保留 cookie 等下次刷新,
// 前端按瞬态失败处理(不弹"登录已失效"、不强制重登)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
return
}
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
@@ -264,7 +273,8 @@ func (h *Handlers) Refresh(c *gin.Context) {
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
// access JWT 登出后 tv 不递增,但链头已撤销 → familySessionActive 为 false,
// ValidateClaims 在每个请求上即时拒绝旧 JWT,长 TTL 下亦无残留有效窗口。
func (h *Handlers) Logout(c *gin.Context) {
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
_ = h.Auth.RevokeRefreshToken(refreshToken)
@@ -309,7 +319,7 @@ func (h *Handlers) MyLoginDevices(c *gin.Context) {
}
familyID := claims.FamilyID
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil {
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, ip, ua, claims.ID); fid != 0 {
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, claims.ID); fid != 0 {
familyID = fid
}
}
@@ -372,8 +382,11 @@ func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin
"role": user.Role,
"board_ids": boardIDs,
"can_manage_messages": user.CanManageMessages,
"level": user.Level,
"total_points": user.TotalPoints,
// 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮,
// 最终权限以后端 RequirePerm 校验为准
"perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages),
"level": user.Level,
"total_points": user.TotalPoints,
}
if badges != nil {
body["badges"] = badges

View File

@@ -51,11 +51,11 @@ func (h *Handlers) BoardSidebar(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
"board": data.Board,
"moderators": data.Moderators,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"checkin": data.Checkin,
"board": data.Board,
"moderators": data.Moderators,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"checkin": data.Checkin,
})
}

View File

@@ -4,6 +4,8 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
@@ -42,6 +44,26 @@ func chatRoomID(c *gin.Context) (uint, bool) {
return uint(id), true
}
// formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用
func formatMuteDurationCN(minutes int) string {
switch {
case minutes >= 30*24*60:
return "30 天"
case minutes >= 7*24*60:
return "7 天"
case minutes >= 3*24*60:
return "3 天"
case minutes >= 24*60:
return "1 天"
case minutes >= 12*60:
return "12 小时"
case minutes >= 60:
return "1 小时"
default:
return strconv.Itoa(minutes) + " 分钟"
}
}
func (h *Handlers) chatOversee(userID uint) bool {
actor, err := h.Auth.LoadActor(userID)
if err != nil || actor == nil {
@@ -219,11 +241,32 @@ func (h *Handlers) JoinChatRoom(c *gin.Context) {
if !ok {
return
}
_, _, err := h.Chat.Join(claims.ID, roomID)
member, joined, err := h.Chat.Join(claims.ID, roomID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
// 首次加入则向房间广播系统提示 + 成员变更
if joined && member != nil {
displayName := h.Chat.DisplayName(claims.ID)
if displayName != "" {
sysMsg, _ := h.Chat.SendSystemMessage(roomID, displayName+" 加入了群聊")
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{
"room_id": roomID,
"status": "joined",
"user_id": claims.ID,
},
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -279,6 +322,35 @@ func (h *Handlers) InviteChatMembers(c *gin.Context) {
Data: gin.H{"room_id": roomID, "status": "invited"},
})
}
// 被邀请者入群时向房间广播系统提示 + 成员变更
if len(added) > 0 {
var names []string
for _, uid := range added {
if n := h.Chat.DisplayName(uid); n != "" {
names = append(names, n)
}
}
if len(names) > 0 {
content := strings.Join(names, "、") + " 加入了群聊"
sysMsg, _ := h.Chat.SendSystemMessage(roomID, content)
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
}
for _, uid := range added {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{
"room_id": roomID,
"status": "joined",
"user_id": uid,
},
})
}
}
c.JSON(http.StatusOK, gin.H{"added": added})
}
@@ -322,7 +394,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SetChatMemberMute 禁言/解禁成员
// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
claims := middleware.CurrentUser(c)
roomID, ok := chatRoomID(c)
@@ -335,17 +407,58 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) {
return
}
var body struct {
Muted bool `json:"muted"`
Muted bool `json:"muted"`
DurationMinutes int `json:"duration_minutes"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil {
duration := time.Duration(body.DurationMinutes) * time.Minute
targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID))
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted})
// 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表
var mutedUntil *time.Time
if body.Muted && body.DurationMinutes > 0 {
t := time.Now().Add(duration)
mutedUntil = &t
}
// 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等)
var sysContent string
if body.Muted {
if body.DurationMinutes > 0 {
sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes)
} else {
sysContent = targetName + " 已被禁言 永久"
}
} else {
sysContent = targetName + " 的禁言已被解除"
}
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染);
// Data 与普通消息一致直接放消息体(扁平结构),前端按 room_id 匹配后才能即时上屏
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMemberMuted,
Data: gin.H{
"room_id": roomID,
"user_id": uint(targetID),
"muted": body.Muted,
"muted_until": mutedUntil,
"duration_minutes": body.DurationMinutes,
"target_name": targetName,
"operator_id": claims.ID,
},
})
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes})
}
// SetChatMemberRole 站长任命/撤销群管理员
@@ -619,7 +732,9 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, ne
}
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览:
// 已撤回正文不得残留在侧栏预览里。预览回滚到上一条真实消息由 RecallMessage
// 回退 last_message_id 完成,本推送只负责即时抹掉正文、不产生角标与置顶。
if h.Chat == nil {
return
}

View File

@@ -82,38 +82,6 @@ func (h *Handlers) PostComments(c *gin.Context) {
})
}
// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致),
// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。
func (h *Handlers) CommentLocation(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
// 待审/被拒帖子的评论不对公众开放
var viewerID uint
var loadActor func() *service.Actor
if claims := middleware.CurrentUser(c); claims != nil {
viewerID = claims.ID
loadActor = h.actorLoader(claims.ID)
}
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
floor := h.Comment.FloorNumber(uint(id), uint(cid))
if floor <= 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"floor": floor})
}
// CreateCommentRequest 评论请求
type CreateCommentRequest struct {
Content string `json:"content" binding:"required,min=1,max=5000"`

View File

@@ -0,0 +1,208 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// currentUserID 从 gin 上下文取登录用户 ID;RequireAuth 已保证存在
func currentUserID(c *gin.Context) (uint, bool) {
v, ok := c.Get("user")
if !ok {
return 0, false
}
claims, ok := v.(*service.UserClaims)
if !ok || claims.ID == 0 {
return 0, false
}
return claims.ID, true
}
// GET /api/friend-links 公开列表(仅 approved)
func (h *Handlers) GetFriendLinks(c *gin.Context) {
links, err := h.FriendLink.ListApproved()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"links": links})
}
// POST /api/friend-links/apply 登录用户提交申请
func (h *Handlers) ApplyFriendLink(c *gin.Context) {
uid, ok := currentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
fl, err := h.FriendLink.Apply(in, uid)
if err != nil {
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "提交申请失败"})
return
}
c.JSON(http.StatusOK, gin.H{"link": fl})
}
// GET /api/friend-links/mine 当前登录用户自己的申请记录(含待审/拒绝与拒绝理由)
func (h *Handlers) MyFriendLinks(c *gin.Context) {
uid, ok := currentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
links, err := h.FriendLink.ListMine(uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取申请记录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"links": links})
}
// PUT /api/friend-links/:id 申请人修改自己的申请;已通过/已驳回的修改后回退待审
func (h *Handlers) OwnerUpdateFriendLink(c *gin.Context) {
uid, ok := currentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.OwnerUpdate(uint(id), uid, in); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存修改失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/admin/friend-links 后台全量列表
func (h *Handlers) AdminListFriendLinks(c *gin.Context) {
links, err := h.FriendLink.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"links": links})
}
// POST /api/admin/friend-links 站长直接新建(status=approved)
func (h *Handlers) AdminCreateFriendLink(c *gin.Context) {
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
fl, err := h.FriendLink.AdminCreate(in)
if err != nil {
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"link": fl})
}
// PUT /api/admin/friend-links/:id 全量更新内容(不改 status)
func (h *Handlers) AdminUpdateFriendLink(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
var in service.FriendLinkInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.AdminUpdate(uint(id), in); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// POST /api/admin/friend-links/:id/status 审批:body {status, reject_reason?}
func (h *Handlers) AdminSetFriendLinkStatus(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
var body struct {
Status string `json:"status"`
RejectReason string `json:"reject_reason"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.AdminSetStatus(uint(id), body.Status, body.RejectReason); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
if errors.Is(err, service.ErrFriendLinkInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "审批参数无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新状态失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// DELETE /api/admin/friend-links/:id 软删除
func (h *Handlers) AdminDeleteFriendLink(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.FriendLink.AdminDelete(uint(id)); err != nil {
if errors.Is(err, service.ErrFriendLinkNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除友情链接失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -10,36 +10,39 @@ import (
// Handlers 聚合所有服务引用
type Handlers struct {
Ops *service.Operations
Cfg *config.Config
Hub *realtime.Hub
Auth *service.AuthService
Board *service.BoardService
Post *service.PostService
Comment *service.CommentService
Like *service.LikeService
Favorite *service.FavoriteService
Follow *service.FollowService
Notification *service.NotificationService
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
SitePage *service.SitePageService
Upload *service.UploadService
PostFile *service.PostFileService
Points *service.PointsService
Setting *service.SettingService
AdminUser *service.AdminUserService
LegacyImport *service.LegacyImportService
Moderation *service.ModerationService
Chat *service.ChatService
Visit *service.VisitStatsService
Analytics *service.AnalyticsService
HidePwd *service.HidePasswordCookie
Ads *service.AdService
Sidebar *service.SidebarService
Badge *service.BadgeService
Ops *service.Operations
Cfg *config.Config
Hub *realtime.Hub
Auth *service.AuthService
Board *service.BoardService
Post *service.PostService
Comment *service.CommentService
Like *service.LikeService
Favorite *service.FavoriteService
Follow *service.FollowService
Notification *service.NotificationService
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
SitePage *service.SitePageService
Upload *service.UploadService
PostFile *service.PostFileService
Points *service.PointsService
Setting *service.SettingService
AdminUser *service.AdminUserService
LegacyImport *service.LegacyImportService
Moderation *service.ModerationService
Chat *service.ChatService
Visit *service.VisitStatsService
Analytics *service.AnalyticsService
HidePwd *service.HidePasswordCookie
Ads *service.AdService
Sidebar *service.SidebarService
FriendLink *service.FriendLinkService
Badge *service.BadgeService
LeaderboardSvc *service.LeaderboardService
LibrarySvc *service.LibraryService
SnippetSvc *service.SnippetService
}
// resolvePublishStatus 决定新帖/新评的初始状态:

499
backend/handler/library.go Normal file
View File

@@ -0,0 +1,499 @@
package handler
import (
"errors"
"net/http"
"net/url"
"os"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ---------- 公开端 ----------
// LibraryList 已发布条目目录
func (h *Handlers) LibraryList(c *gin.Context) {
list, err := h.LibrarySvc.ListPublished()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
return
}
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
}
// LibraryDetail 公开条目详情(按 slug,仅已发布)
func (h *Handlers) LibraryDetail(c *gin.Context) {
d, err := h.LibrarySvc.GetPublishedBySlug(c.Param("slug"))
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取条目失败"})
return
}
c.JSON(http.StatusOK, gin.H{"doc": d})
}
// LibraryFileDownload 强制下载(attachment;成功后计数)
func (h *Handlers) LibraryFileDownload(c *gin.Context) {
f, ok := h.libraryPublicFile(c)
if !ok {
return
}
h.LibrarySvc.IncrDownload(f.ID)
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(f.Name))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "sandbox")
ct := f.MIME
if ct == "" {
ct = "application/octet-stream"
}
c.Header("Content-Type", ct)
c.File(h.LibrarySvc.FilePath(f))
}
// libraryPublicFile 公开文件公共校验(存在 + 所属条目已发布)
func (h *Handlers) libraryPublicFile(c *gin.Context) (*model.LibraryFile, bool) {
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
return nil, false
}
f, err := h.LibrarySvc.GetPublicFile(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return nil, false
}
return f, true
}
// ---------- 管理端 ----------
// AdminListLibraryDocs 后台条目列表(含未发布)
func (h *Handlers) AdminListLibraryDocs(c *gin.Context) {
list, err := h.LibrarySvc.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
return
}
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
}
// AdminCreateLibraryDoc 新建条目
func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) {
var in service.LibraryInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
var creatorID uint
if claims := middleware.CurrentUser(c); claims != nil {
creatorID = claims.ID
}
d, err := h.LibrarySvc.Create(&in, creatorID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"doc": d})
}
// AdminUpdateLibraryDoc 编辑条目
func (h *Handlers) AdminUpdateLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in service.LibraryInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
d, err := h.LibrarySvc.Update(uint(id), &in)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) || errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"doc": d})
}
// AdminDeleteLibraryDoc 删除条目(软删)
func (h *Handlers) AdminDeleteLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
if err := h.LibrarySvc.Delete(uint(id)); err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminPurgeLibraryDoc 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件)
func (h *Handlers) AdminPurgeLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
if err := h.LibrarySvc.Purge(uint(id)); err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
if errors.Is(err, service.ErrLibraryNotDeleted) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminUploadLibraryFile 上传文件到条目
func (h *Handlers) AdminUploadLibraryFile(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
maxBytes, err := h.Setting.AttachmentMaxBytes()
if err != nil || maxBytes < 1 {
maxBytes = service.FileMaxBytes
}
overhead := int64(64 << 10) // multipart 边界开销
limit := maxBytes + overhead
if c.Request.ContentLength > limit {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件过大"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
return
}
if file.Size > maxBytes {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
src, err := file.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"})
return
}
defer src.Close()
f, err := h.LibrarySvc.AddFile(uint(docID), claims.ID, file.Filename, src)
if err != nil {
if errors.Is(err, service.ErrLibraryExtDenied) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"file": f})
}
// AdminDeleteLibraryFile 删除条目文件
func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
return
}
if err := h.LibrarySvc.DeleteFile(uint(id)); err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ---------- 管理端:导出(迁移 / 备份) ----------
// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP;
// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。
func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) {
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename))
c.Header("X-Content-Type-Options", "nosniff")
c.Status(http.StatusOK)
if err := exp.WriteZip(c.Writer); err != nil {
_ = c.Error(err)
}
}
// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP)
func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
exp, err := h.LibrarySvc.BuildBookExport(uint(id))
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
return
}
h.writeLibraryExport(c, exp)
}
// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引)
func (h *Handlers) AdminExportAllLibrary(c *gin.Context) {
exp, err := h.LibrarySvc.BuildAllExport()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
return
}
h.writeLibraryExport(c, exp)
}
// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名)
func (h *Handlers) AdminImportLibrary(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
const overhead = 64 << 10 // multipart 边界开销
limit := int64(service.LibraryImportMaxBytes) + overhead
if c.Request.ContentLength > limit {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
fh, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"})
return
}
if fh.Size > service.LibraryImportMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"})
return
}
mode := c.PostForm("mode")
if mode == "" {
mode = service.LibraryImportModeCreate
}
tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"})
return
}
defer os.Remove(tmpPath)
rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID)
if err != nil {
switch {
case errors.Is(err, service.ErrLibraryImportBadZip),
errors.Is(err, service.ErrLibraryImportFormat),
errors.Is(err, service.ErrLibraryImportVer),
errors.Is(err, service.ErrLibraryImportEmpty),
errors.Is(err, service.ErrLibraryImportMode):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"report": rep})
}
// ---------- 管理端:章节 ----------
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)
func (h *Handlers) AdminCreateLibrarySection(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in service.SectionInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sec, err := h.LibrarySvc.CreateSection(uint(docID), &in)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"section": sec})
}
// AdminUpdateLibrarySection 编辑章节标题与正文
func (h *Handlers) AdminUpdateLibrarySection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
return
}
var in service.SectionInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sec, err := h.LibrarySvc.UpdateSection(uint(id), &in)
if err != nil {
if errors.Is(err, service.ErrSectionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"section": sec})
}
// AdminDeleteLibrarySection 删除章节(章级联删除其下小节)
func (h *Handlers) AdminDeleteLibrarySection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
return
}
if err := h.LibrarySvc.DeleteSection(uint(id)); err != nil {
if errors.Is(err, service.ErrSectionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminMoveLibrarySection 章节上移/下移(同父兄弟间交换顺序)
func (h *Handlers) AdminMoveLibrarySection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
return
}
var in struct {
Direction string `json:"direction"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.LibrarySvc.MoveSection(uint(id), in.Direction); err != nil {
if errors.Is(err, service.ErrSectionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminImportLibraryMarkdown 导入 md 并按标题拆章
func (h *Handlers) AdminImportLibraryMarkdown(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in struct {
Filename string `json:"filename"`
Content string `json:"content"`
SplitLevel string `json:"split_level"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sections, err := h.LibrarySvc.ImportMarkdown(uint(docID), in.Filename, in.Content, in.SplitLevel)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
}
// AdminImportLibraryMarkdownBatch 批量导入 md:多个文件按传入顺序依次拆章
func (h *Handlers) AdminImportLibraryMarkdownBatch(c *gin.Context) {
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
return
}
var in struct {
Files []service.ImportFileInput `json:"files"`
SplitLevel string `json:"split_level"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
sections, err := h.LibrarySvc.ImportMarkdownBatch(uint(docID), in.Files, in.SplitLevel)
if err != nil {
if errors.Is(err, service.ErrLibraryNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
}

View File

@@ -5,9 +5,18 @@ import (
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/gin-gonic/gin"
)
// notificationView 通知列表项:附带关联评论的楼层相对锚点,
// 供前端拼 #comment-{floor}(主楼)或 #comment-{floor}-r{comment_id}(楼中楼)。
type notificationView struct {
*model.Notification
CommentFloor uint `json:"comment_floor"`
CommentIsRoot bool `json:"comment_is_root"`
}
// Notifications 获取当前用户的通知列表(分页)
func (h *Handlers) Notifications(c *gin.Context) {
claims := middleware.CurrentUser(c)
@@ -19,8 +28,24 @@ func (h *Handlers) Notifications(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
commentIDs := make([]uint, 0, len(list))
for _, n := range list {
if n.CommentID > 0 {
commentIDs = append(commentIDs, n.CommentID)
}
}
anchors := h.Comment.AnchorsByCommentIDs(commentIDs)
views := make([]notificationView, 0, len(list))
for i := range list {
v := notificationView{Notification: &list[i]}
if a, ok := anchors[list[i].CommentID]; ok {
v.CommentFloor = a.Floor
v.CommentIsRoot = a.IsRoot
}
views = append(views, v)
}
c.JSON(http.StatusOK, gin.H{
"notifications": nonNilSlice(list),
"notifications": nonNilSlice(views),
"total": total,
"page": page,
"size": size,

View File

@@ -245,8 +245,12 @@ func (h *Handlers) SiteState(c *gin.Context) {
c.JSON(503, gin.H{"error": "状态暂不可用"})
return
}
urlStyle, e := h.Setting.UrlStyle()
if e != nil {
urlStyle = service.DefaultUrlStyle
}
c.Header("Cache-Control", "no-store")
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL})
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL, "url_style": urlStyle})
}
func (h *Handlers) SendEmailCode(c *gin.Context) {
var req struct {
@@ -258,6 +262,10 @@ func (h *Handlers) SendEmailCode(c *gin.Context) {
return
}
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
if errors.Is(e, service.ErrEmailRegistered) {
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
return
}
if !h.quotaResponse(c, wait, e) {
return
}
@@ -309,7 +317,8 @@ func (h *Handlers) PublicObject(c *gin.Context) {
defer r.Close()
c.Header("Content-Type", m)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "private, no-store")
// 对象 ID 按上传随机生成不覆盖,直出内容可短缓存(302 预签名分支不缓存,防过期地址复用)
c.Header("Cache-Control", "public, max-age=86400")
c.Status(200)
_, _ = io.Copy(c.Writer, r)
}

View File

@@ -29,20 +29,20 @@ func (h *Handlers) Overview(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"announcements": data.Announcements,
"announcements_total": data.AnnouncementsTotal,
"sidebar_pages": data.SidebarPages,
"new_users": data.NewUsers,
"checkin": data.Checkin,
"ads": data.Ads,
"ads_panel_title": data.AdsPanelTitle,
"ads_enabled": data.AdsEnabled,
"sponsors": data.Sponsors,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"announcements": data.Announcements,
"announcements_total": data.AnnouncementsTotal,
"sidebar_pages": data.SidebarPages,
"new_users": data.NewUsers,
"checkin": data.Checkin,
"ads": data.Ads,
"ads_panel_title": data.AdsPanelTitle,
"ads_enabled": data.AdsEnabled,
"sponsors": data.Sponsors,
"sponsors_panel_title": data.SponsorsPanelTitle,
"sponsors_enabled": data.SponsorsEnabled,
"sponsors_enabled": data.SponsorsEnabled,
})
}

View File

@@ -8,6 +8,7 @@ import (
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/middleware"
@@ -16,13 +17,20 @@ import (
"github.com/gin-gonic/gin"
)
// maxSearchKeywordRunes 帖子搜索关键词长度上限(字符数,与前端搜索框 maxLength 一致)
const maxSearchKeywordRunes = 100
// Posts 获取帖子列表
func (h *Handlers) Posts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
sort := c.DefaultQuery("sort", "latest")
keyword := c.Query("keyword")
keyword := strings.TrimSpace(c.Query("keyword"))
// 搜索词上限:前端输入框同限;超长词 ILIKE 必然无意义结果,静默截断
if runes := []rune(keyword); len(runes) > maxSearchKeywordRunes {
keyword = string(runes[:maxSearchKeywordRunes])
}
recommended := c.Query("recommended") == "true"
var viewerID uint

View File

@@ -57,6 +57,7 @@ type updateSettingsRequest struct {
BrandMark *string `json:"brand_mark"`
BrandLogoSize *string `json:"brand_logo_size"`
BrandLogoFit *string `json:"brand_logo_fit"`
BrandSloganVisible *bool `json:"brand_slogan_visible"`
FooterLinks *[]service.FooterLink `json:"footer_links"`
PointsCheckinBase *int `json:"points_checkin_base"`
@@ -73,8 +74,14 @@ type updateSettingsRequest struct {
NecroReplyAfterHours *int `json:"necro_reply_after_hours"`
NecroReplyPenalty *int `json:"necro_reply_penalty"`
Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"`
Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"`
UrlStyle *string `json:"url_style"`
MobileTabs *[]string `json:"mobile_tabs"`
HeaderNav *[]service.HeaderNavItem `json:"header_nav"`
}
func settingsPayload(saved service.PublicSiteSettings) gin.H {
@@ -85,9 +92,9 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"site_name": saved.SiteName,
"site_description": saved.SiteDescription,
"allow_register": saved.AllowRegister,
"allow_comments": saved.AllowComments,
"comments_require_login": saved.CommentsRequireLogin,
"allow_messages": saved.AllowMessages,
"allow_comments": saved.AllowComments,
"comments_require_login": saved.CommentsRequireLogin,
"allow_messages": saved.AllowMessages,
"post_cooldown_hours": saved.PostCooldownHours,
"code_block_auto_fold": saved.CodeBlockAutoFold,
"code_block_fold_lines": saved.CodeBlockFoldLines,
@@ -114,6 +121,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"brand_mark": saved.BrandMark,
"brand_logo_size": saved.BrandLogoSize,
"brand_logo_fit": saved.BrandLogoFit,
"brand_slogan_visible": saved.BrandSloganVisible,
"footer_links": saved.FooterLinks,
"points_checkin_base": saved.PointsCheckinBase,
@@ -125,13 +133,19 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"points_reply_daily_cap": saved.PointsReplyDailyCap,
"points_recommend_reward": saved.PointsRecommendReward,
"post_edit_lock_hours": saved.PostEditLockHours,
"comment_edit_lock_hours": saved.CommentEditLockHours,
"necro_reply_after_hours": saved.NecroReplyAfterHours,
"necro_reply_penalty": saved.NecroReplyPenalty,
"post_edit_lock_hours": saved.PostEditLockHours,
"comment_edit_lock_hours": saved.CommentEditLockHours,
"necro_reply_after_hours": saved.NecroReplyAfterHours,
"necro_reply_penalty": saved.NecroReplyPenalty,
"levels": saved.Levels,
"levels": saved.Levels,
"levels_fx": saved.LevelsFx,
"url_style": saved.UrlStyle,
"mobile_tabs": saved.MobileTabs,
"header_nav": saved.HeaderNav,
}
}
@@ -148,13 +162,15 @@ func (req *updateSettingsRequest) hasAny() bool {
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
req.BrandSloganVisible != nil ||
req.FooterLinks != nil ||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
req.UrlStyle != nil || req.MobileTabs != nil || req.HeaderNav != nil
}
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
@@ -527,6 +543,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.BrandSloganVisible != nil {
if err := h.Setting.SetBrandSloganVisible(*req.BrandSloganVisible); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.FooterLinks != nil {
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
@@ -537,6 +559,36 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.UrlStyle != nil {
if err := h.Setting.SetUrlStyle(*req.UrlStyle); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "链接风格无效"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.MobileTabs != nil {
if err := h.Setting.SetMobileTabs(*req.MobileTabs); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "移动端底栏配置无效(至少保留一项,且取值须在允许列表内)"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.HeaderNav != nil {
if err := h.Setting.SetHeaderNav(*req.HeaderNav); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "顶部导航配置无效(最多 8 项,内置项须在允许列表内,自定义项名称 1–16 字、地址须为站内路径或 http(s))"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
saved, err := h.Setting.Public()
if err != nil {

View File

@@ -101,3 +101,25 @@ func (h *Handlers) AdminDeleteSitePage(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminPurgeSitePage 彻底删除单页(仅限已软删)
func (h *Handlers) AdminPurgeSitePage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面 ID"})
return
}
if err := h.SitePage.Purge(uint(id)); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
if errors.Is(err, service.ErrSitePageNotDeleted) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

424
backend/handler/snippet.go Normal file
View File

@@ -0,0 +1,424 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// snippetError 统一错误映射
func snippetError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrSnippetNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "代码片段不存在"})
case errors.Is(err, service.ErrSnippetForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": "无权操作该代码片段"})
case errors.Is(err, service.ErrSnippetBadInput):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
case errors.Is(err, service.ErrCommentForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": "无权操作该评论"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器错误"})
}
}
// SnippetList 公开片段广场
func (h *Handlers) SnippetList(c *gin.Context) {
q := c.Query("q")
lang := c.Query("lang")
sort := c.Query("sort")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "12"))
items, total, err := h.SnippetSvc.ListPublic(q, lang, sort, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetLanguages 语言白名单
func (h *Handlers) SnippetLanguages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"languages": service.SnippetLanguages()})
}
// SnippetStats 广场聚合统计
func (h *Handlers) SnippetStats(c *gin.Context) {
stats, err := h.SnippetSvc.Stats()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"})
return
}
c.JSON(http.StatusOK, gin.H{"stats": stats})
}
// SnippetDetail 公开片段详情
func (h *Handlers) SnippetDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var viewerID uint
if claims := middleware.CurrentUser(c); claims != nil {
viewerID = claims.ID
}
isStaff := h.isStaff(c)
detail, err := h.SnippetSvc.GetPublic(uint(id), viewerID, isStaff)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"snippet": detail})
}
// SnippetMine 我的片段列表
func (h *Handlers) SnippetMine(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
q := c.Query("q")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "12"))
items, total, err := h.SnippetSvc.ListMine(claims.ID, q, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetCreate 创建片段
func (h *Handlers) SnippetCreate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
var in service.SnippetInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
snippet, err := h.SnippetSvc.Create(&in, claims.ID)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"snippet": snippet})
}
// SnippetUpdate 更新片段
func (h *Handlers) SnippetUpdate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var in service.SnippetInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
snippet, err := h.SnippetSvc.Update(uint(id), &in, claims.ID, h.isStaff(c))
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"snippet": snippet})
}
// SnippetDelete 删除片段(owner 或 staff)
func (h *Handlers) SnippetDelete(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
if err := h.SnippetSvc.Delete(uint(id), claims.ID, h.isStaff(c)); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SnippetFork Fork 公开片段
func (h *Handlers) SnippetFork(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
snippet, err := h.SnippetSvc.Fork(uint(id), claims.ID)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"snippet": snippet})
}
// SnippetCopy 记录复制(节流由 service 内部处理)
func (h *Handlers) SnippetCopy(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
key := c.ClientIP()
if claims := middleware.CurrentUser(c); claims != nil {
key = "user:" + strconv.FormatUint(uint64(claims.ID), 10)
}
if err := h.SnippetSvc.RecordCopy(uint(id), key); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SnippetHistory 片段编辑历史
func (h *Handlers) SnippetHistory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
items, total, err := h.SnippetSvc.ListHistory(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取历史失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetForks 公开 fork 列表
func (h *Handlers) SnippetForks(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "12"))
items, total, err := h.SnippetSvc.ListForks(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取 Fork 列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetComments 片段评论列表
func (h *Handlers) SnippetComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
items, total, err := h.SnippetSvc.ListComments(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取评论失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// SnippetCommentCreate 创建片段评论
func (h *Handlers) SnippetCommentCreate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
snippetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var in struct {
Content string `json:"content"`
ParentID *uint `json:"parent_id"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
comment, err := h.SnippetSvc.CreateComment(claims.ID, uint(snippetID), in.Content, in.ParentID)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"comment": comment})
}
// SnippetCommentUpdate 更新片段评论
func (h *Handlers) SnippetCommentUpdate(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
var in struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
comment, err := h.SnippetSvc.UpdateComment(uint(cid), claims.ID, in.Content)
if err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"comment": comment})
}
// SnippetCommentDelete 删除片段评论
func (h *Handlers) SnippetCommentDelete(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
if err := h.SnippetSvc.DeleteComment(uint(cid), claims.ID, h.isStaff(c)); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SnippetCommentHistory 片段评论编辑历史(登录即可见;分页)
func (h *Handlers) SnippetCommentHistory(c *gin.Context) {
claims := middleware.CurrentUser(c)
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil || cid == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
items, total, err := h.SnippetSvc.ListCommentHistory(h.loadActor(claims.ID), uint(cid), page, size)
if err != nil {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"})
}
return
}
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 10
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"size": size,
})
}
// AdminSnippetList 管理端片段列表
func (h *Handlers) AdminSnippetList(c *gin.Context) {
q := c.Query("q")
visibility := c.Query("visibility")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
items, total, err := h.SnippetSvc.AdminList(q, visibility, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total})
}
// AdminSnippetDelete 管理端软删片段
func (h *Handlers) AdminSnippetDelete(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
if err := h.SnippetSvc.AdminDelete(uint(id), claims.ID); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminSnippetSetVisibility 管理端强制修改可见性
func (h *Handlers) AdminSnippetSetVisibility(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"})
return
}
var in struct {
Visibility string `json:"visibility"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.SnippetSvc.AdminSetVisibility(uint(id), in.Visibility); err != nil {
snippetError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// isStaff 判断当前用户是否为管理角色
func (h *Handlers) isStaff(c *gin.Context) bool {
claims := middleware.CurrentUser(c)
if claims == nil {
return false
}
return model.IsStaff(model.Role(claims.Role))
}

View File

@@ -27,3 +27,20 @@ func (h *Handlers) TimelineFromGit(c *gin.Context) {
}
c.JSON(http.StatusOK, result)
}
// TimelineFromReleases 从 Git releases 页 URL 导入时间线条目(登录 + CSRF + 限流)
func (h *Handlers) TimelineFromReleases(c *gin.Context) {
var req struct {
URLs []string `json:"urls"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
result, err := h.Setting.ImportTimelineFromReleases(req.URLs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}

View File

@@ -3,10 +3,14 @@ package handler
import (
"errors"
"io"
"mime"
"net/http"
"path"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -49,7 +53,7 @@ func (h *Handlers) UploadAvatar(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP)
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP / GIF;除 GIF 外自动转存 WebP)
func (h *Handlers) UploadImage(c *gin.Context) {
claims := middleware.CurrentUser(c)
@@ -99,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) {
}
defer f.Close()
att, err := h.Upload.SaveImage(claims.ID, f)
att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source"))
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
@@ -311,6 +315,122 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": url})
}
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
var req struct {
AttachmentID uint `json:"attachment_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil {
c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing})
return
}
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// brandAliasSlots 固定地址路径段 → 品牌槽位
var brandAliasSlots = map[string]string{
"logo-light": service.BrandSlotLight,
"logo-dark": service.BrandSlotDark,
"favicon": service.BrandSlotFavicon,
}
// BrandImage 品牌图固定对外地址(GET /api/brand/logo-light|logo-dark|favicon):
// 友链等外部引用此地址,站点换图后按设置解析到当前图,地址永不变。
func (h *Handlers) BrandImage(c *gin.Context) {
slot, ok := brandAliasSlots[c.Param("slot")]
if !ok {
c.Status(http.StatusNotFound)
return
}
var url string
var err error
switch slot {
case service.BrandSlotLight:
url, err = h.Setting.LogoLightURL()
case service.BrandSlotDark:
url, err = h.Setting.LogoDarkURL()
default:
url, err = h.Setting.FaviconURL()
}
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
if url == "" {
c.Status(http.StatusNotFound)
return
}
f, err := h.Upload.OpenBrandFile(url)
if err != nil {
c.Status(http.StatusNotFound)
return
}
defer f.Close()
// 扩展名定 Content-Type(SVG 不能靠嗅探);短缓存让换图在友链侧尽快生效
if ct := mime.TypeByExtension(path.Ext(url)); ct != "" {
c.Header("Content-Type", ct)
}
c.Header("Cache-Control", "public, max-age=3600")
http.ServeContent(c.Writer, c.Request, path.Base(url), time.Time{}, f)
}
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
items, counts, err := h.Upload.AdminMediaLibrary()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取媒体库失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "counts": counts, "total": len(items)})
}
// MediaLibraryThumb 媒体库网格缩略图(?u=/uploads/images/xx.webp),失败回退由前端处理
func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
data, err := h.Upload.MediaThumb(c.Query("u"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "缩略图不可用"})
return
}
c.Header("Cache-Control", "private, max-age=86400")
c.Data(http.StatusOK, "image/webp", data)
}
// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>):
// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底
func (h *Handlers) ImageVariant(c *gin.Context) {
w, err := strconv.Atoi(c.Query("w"))
if err != nil || !service.IsVariantWidth(w) {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"})
return
}
data, m, err := h.Upload.Variant(c.Query("u"), w)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"})
return
}
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, m, data)
}
func brandTooLarge(slot string) string {
if slot == service.BrandSlotFavicon {
return "Favicon 不能超过 512KB"

View File

@@ -130,9 +130,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
}
// 访客与该用户的关注关系(关注按钮初始态)
userPayload["is_following"] = isFollowing
// 邮箱仅本人可见,打码展示(j***@163.com)
// 邮箱仅本人可见;仅本人访问时下发明文(用于账号信息展示与资料表单回填)
if viewerID == user.ID && user.Email != "" {
userPayload["email"] = maskEmail(user.Email)
userPayload["email"] = user.Email
}
// 徽章墙(失败不阻塞资料返回)
if h.Badge != nil {
@@ -144,11 +144,11 @@ func (h *Handlers) UserProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"user": userPayload,
"stats": gin.H{
"post_count": postCount,
"comment_count": commentCount,
"points": pointsTotal,
"streak": streak,
"favorite_count": favoriteCount,
"post_count": postCount,
"comment_count": commentCount,
"points": pointsTotal,
"streak": streak,
"favorite_count": favoriteCount,
"following_count": followingCount,
},
"posts": nonNilSlice(posts),
@@ -158,21 +158,6 @@ func (h *Handlers) UserProfile(c *gin.Context) {
})
}
// maskEmail 邮箱打码:保留首字符与 @ 后域名(j***@163.com)
func maskEmail(email string) string {
at := -1
for i, r := range email {
if r == '@' {
at = i
break
}
}
if at <= 0 {
return "***"
}
return email[:1] + "***" + email[at:]
}
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
func (h *Handlers) UserComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)

View File

@@ -85,7 +85,32 @@ func (m *AuthMiddleware) RequireStaff() gin.HandlerFunc {
}
}
// RequirePerm 功能点鉴权,必须接在 RequireStaff 之后
// RequireActor 必须登录并加载实时 Actor(不要求管理角色),
// 供账号级渠道权限(非管理角色也可被单独授予)所在路由组使用;
// 功能点鉴权由后续 RequirePerm 完成
func (m *AuthMiddleware) RequireActor() gin.HandlerFunc {
return func(c *gin.Context) {
claims, ok := m.parseToken(c)
if !ok {
if c.GetBool(AccountBannedKey) {
bannedJSON(c)
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
actor, err := m.auth.LoadActor(claims.ID)
if err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权限执行该操作"})
return
}
c.Set("user", claims)
c.Set(ActorKey, actor)
c.Next()
}
}
// RequirePerm 功能点鉴权,必须接在 RequireStaff / RequireActor 之后
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
return func(c *gin.Context) {
actor := CurrentActor(c)

View File

@@ -0,0 +1,19 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
// UploadStaticCache 上传文件长缓存:/uploads 下文件名均为上传时随机生成、
// 写入后不覆盖(SaveAvatar/SaveImage 等均新建文件),URL 内容恒定,可安全
// immutable。PageSpeed「缓存生命周期」修复项。
func UploadStaticCache() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
c.Next()
}
}

View File

@@ -19,26 +19,26 @@ const (
// Ad 赞助广告(游客可申购,管理员审核后展示于首页侧栏)
type Ad struct {
ID uint `gorm:"primaryKey" json:"id"`
Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text
Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired
ContactEmail string `gorm:"size:128;not null" json:"contact_email"`
LinkURL string `gorm:"size:512;not null" json:"link_url"`
ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告
Title string `gorm:"size:64" json:"title"` // 文字广告
TextColor string `gorm:"size:16" json:"text_color"`
BgColor string `gorm:"size:16" json:"bg_color"`
DurationDays int `gorm:"not null" json:"duration_days"`
PaymentID string `gorm:"size:64;not null" json:"payment_id"`
BuyerNote string `gorm:"size:200" json:"buyer_note"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
RejectReason string `gorm:"size:200" json:"reject_reason"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
SubmitIP string `gorm:"size:45" json:"-"`
ReviewedAt *time.Time `json:"reviewed_at"`
ReviewedBy *uint `json:"reviewed_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text
Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired
ContactEmail string `gorm:"size:128;not null" json:"contact_email"`
LinkURL string `gorm:"size:512;not null" json:"link_url"`
ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告
Title string `gorm:"size:64" json:"title"` // 文字广告
TextColor string `gorm:"size:16" json:"text_color"`
BgColor string `gorm:"size:16" json:"bg_color"`
DurationDays int `gorm:"not null" json:"duration_days"`
PaymentID string `gorm:"size:64;not null" json:"payment_id"`
BuyerNote string `gorm:"size:200" json:"buyer_note"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
RejectReason string `gorm:"size:200" json:"reject_reason"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
SubmitIP string `gorm:"size:45" json:"-"`
ReviewedAt *time.Time `json:"reviewed_at"`
ReviewedBy *uint `json:"reviewed_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

View File

@@ -0,0 +1,42 @@
package model
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// 默认板块只在空库播种:站长改名/删除板块后重启不得复活默认板块(0.2.1 事故回归)
func TestSeedDefaultBoardsOnlyWhenEmpty(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&Board{}); err != nil {
t.Fatalf("migrate: %v", err)
}
// 空库首启:播种 4 个默认板块
seedDefaultBoards(db)
var n int64
db.Model(&Board{}).Count(&n)
if n != 4 {
t.Fatalf("空库应播种 4 个默认板块,实际 %d", n)
}
// 站长改名后重启:不再复活默认板块
if err := db.Model(&Board{}).Where("name = ?", "综合讨论").Update("name", "新综合").Error; err != nil {
t.Fatalf("rename: %v", err)
}
seedDefaultBoards(db)
db.Model(&Board{}).Count(&n)
if n != 4 {
t.Fatalf("改名后重启不应重复播种,实际 %d 个板块", n)
}
var revived int64
db.Model(&Board{}).Where("name = ?", "综合讨论").Count(&revived)
if revived != 0 {
t.Fatal("默认板块「综合讨论」被复活")
}
}

View File

@@ -56,14 +56,15 @@ func InitDB(dsn string) error {
}
if err := db.AutoMigrate(
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &Blob{},
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
&Badge{}, &UserBadge{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
&ImportRecord{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
&CodeSnippet{}, &SnippetVersion{}, &SnippetComment{}, &SnippetCommentEditHistory{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
@@ -78,6 +79,9 @@ func InitDB(dsn string) error {
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
backfillUserTotalPoints(db)
// 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长
backfillLibraryCreator(db)
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
@@ -146,6 +150,7 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
pairs := []struct{ table, constraint string }{
{"chat_rooms", "fk_chat_rooms_last_message"},
{"notifications", "fk_notifications_room"},
{"chat_messages", "fk_chat_messages_sender"},
}
for _, p := range pairs {
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
@@ -368,6 +373,29 @@ func backfillUserTotalPoints(db *gorm.DB) {
}
}
// backfillLibraryCreator 为历史书库条目(creator_id=0,特性上线前创建或早期导入)
// 回填站长为创建者,保证介绍页「创建者」可展示。幂等:仅影响 creator_id=0 的行;
// 站长不存在(空库)时跳过,等下次启动自愈。
func backfillLibraryCreator(db *gorm.DB) {
var owner User
err := db.Select("id").Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return
}
if err != nil {
log.Printf("[model] 回填书库创建者前置查询失败: %v", err)
return
}
res := db.Exec(`UPDATE library_docs SET creator_id = ? WHERE creator_id = 0`, owner.ID)
if res.Error != nil {
log.Printf("[model] 回填书库创建者失败: %v", res.Error)
return
}
if res.RowsAffected > 0 {
log.Printf("[model] 已回填书库条目创建者为站长 %d 行", res.RowsAffected)
}
}
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
@@ -382,20 +410,23 @@ func backfillBoardDefaults(db *gorm.DB) {
}
}
// seedDefaultBoards 写入默认板块
// seedDefaultBoards 仅在板块表为空(全新站点首启)时写入默认板块。
// 曾按「名字是否存在」补种:站长改名板块后每次重启都会复活一套默认板块
// (真实事故:0.2.1 重启后默认 4 板块重现)。表里已有任何未删除板块即视为已初始化。
func seedDefaultBoards(db *gorm.DB) {
var count int64
db.Model(&Board{}).Count(&count)
if count > 0 {
return
}
defaults := []Board{
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true},
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true},
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true},
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true},
}
for _, b := range defaults {
var count int64
db.Model(&Board{}).Where("name = ?", b.Name).Count(&count)
if count == 0 {
_ = db.Create(&b).Error
}
for i := range defaults {
_ = db.Create(&defaults[i]).Error
}
}

View File

@@ -122,6 +122,7 @@ type User struct {
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
@@ -323,9 +324,9 @@ const (
PointReasonBountyEscrow = "bounty_escrow"
PointReasonBountyRefund = "bounty_refund"
PointReasonBountyAward = "bounty_award"
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭)
@@ -361,12 +362,13 @@ type PostContentUnlock struct {
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
type PostAttachment struct {
ObjectID string `gorm:"size:64;index" json:"-"`
ObjectID string `gorm:"size:96;index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名(blob 去重后即内容 hash)
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"`
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
@@ -486,7 +488,7 @@ type Notification struct {
IsRead bool `gorm:"default:false;index" json:"is_read"`
CreatedAt time.Time `json:"created_at"`
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
// PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键
Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"`
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
@@ -522,6 +524,127 @@ type SitePage struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// LibraryDoc 书库条目(书籍/文档/纯 HTML 页展示;文件独立目录存储,仅经 API 消费)
type LibraryDoc struct {
ID uint `gorm:"primaryKey" json:"id"`
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
Title string `gorm:"size:200;not null" json:"title"`
Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空
CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images)
CoverWidth int `gorm:"not null;default:0" json:"cover_width"` // 封面自然宽(0=未知,前端回退探测)
CoverHeight int `gorm:"not null;default:0" json:"cover_height"` // 封面自然高(0=未知,前端回退探测)
Published bool `gorm:"not null;index" json:"published"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
// EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳,
// 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外)
EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"`
// Author 书籍作者(自由文本,可填多位,如「鲁迅 译 / 某某注」),仅展示用
Author string `gorm:"size:100;not null;default:''" json:"author"`
// CreatorID 条目创建者(站点用户;0=历史数据或导入时无对应用户),创建后不变
CreatorID uint `gorm:"index;not null;default:0" json:"creator_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// LibraryFile 书库文件(html/pdf/epub 等;存 data/library,不进公开静态目录)
type LibraryFile struct {
ID uint `gorm:"primaryKey" json:"id"`
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
UserID uint `gorm:"index;not null" json:"user_id"` // 上传管理员
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
Ext string `gorm:"size:16;not null;default:''" json:"ext"`
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"`
DownloadCount int `gorm:"not null;default:0" json:"download_count"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// LibrarySection 书库章节(两级树:ParentID 空=章,非空=节;正文 markdown)
type LibrarySection struct {
ID uint `gorm:"primaryKey" json:"id"`
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
ParentID *uint `gorm:"index" json:"parent_id"` // nil=章;非空=节,父必须是同条目的章
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text" json:"content"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// CodeSnippet 代码片段:用户所有,公开进广场,私有仅自己可见
type CodeSnippet struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:120;not null" json:"title"`
Description string `gorm:"type:text" json:"description"`
Language string `gorm:"size:32;not null;index" json:"language"`
Code string `gorm:"type:text;not null" json:"code"`
Visibility string `gorm:"size:16;not null;default:private;index" json:"visibility"` // public/private
UserID uint `gorm:"index;not null" json:"user_id"`
ForkedFromID *uint `gorm:"index" json:"forked_from_id"`
CopyCount int `gorm:"not null;default:0" json:"copy_count"`
ViewCount int `gorm:"not null;default:0" json:"view_count"`
CommentCount int `gorm:"not null;default:0" json:"comment_count"`
ForkCount int `gorm:"not null;default:0" json:"fork_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SnippetVersion 编辑前旧值快照(同 PostEditHistory 思路)
type SnippetVersion struct {
ID uint `gorm:"primaryKey" json:"id"`
SnippetID uint `gorm:"index;not null" json:"snippet_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldTitle string `gorm:"size:120;not null;default:''" json:"old_title"`
OldLanguage string `gorm:"size:32;not null;default:''" json:"old_language"`
OldDescription string `gorm:"type:text" json:"old_description"`
OldCode string `gorm:"type:text;not null" json:"old_code"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// SnippetComment 片段评论(楼层树:ParentID 空=主评,非空=回复;RootID=所属主楼)
type SnippetComment struct {
ID uint `gorm:"primaryKey" json:"id"`
SnippetID uint `gorm:"index;not null" json:"snippet_id"`
UserID uint `gorm:"index;not null" json:"user_id,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id"`
RootID *uint `gorm:"index" json:"root_id"`
Depth int `gorm:"not null;default:0" json:"depth"`
Content string `gorm:"type:text;not null" json:"content"`
DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"`
DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SnippetCommentEditHistory 片段评论修订快照(每次编辑前的旧正文;登录用户可见)
type SnippetCommentEditHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
CommentID uint `gorm:"index;not null" json:"comment_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldContent string `gorm:"type:text;not null" json:"old_content"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
const (
SnippetVisibilityPublic = "public"
SnippetVisibilityPrivate = "private"
)
// NormalizeSnippetVisibility 归一化可见性:空或未知值一律回落 private
func NormalizeSnippetVisibility(v string) string {
if v == SnippetVisibilityPublic {
return SnippetVisibilityPublic
}
return SnippetVisibilityPrivate
}
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
type SiteSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
@@ -542,6 +665,24 @@ type SidebarWidget struct {
UpdatedAt time.Time `json:"updated_at"`
}
// FriendLink 友情链接;申请审批制,status=pending 经管理员 approve 后前台可见
type FriendLink struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
URL string `gorm:"size:512;not null" json:"url"`
Description string `gorm:"size:255" json:"description"`
LogoURL string `gorm:"size:512" json:"logo_url"`
Category string `gorm:"size:32;index" json:"category"`
ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected
ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil
RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
type SiteDailyStats struct {
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
@@ -588,12 +729,21 @@ const (
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
)
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
// 附件来源(Source):媒体库分组依据,决定"从图库选图"时是否可直接复用
const (
AttachmentSourcePost = "post" // 帖子/文章编辑器插图(默认)
AttachmentSourceLibraryCover = "library_cover" // 书库封面(选用时已复制过,再次选可直接引用)
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
)
// Attachment 用户上传的附件(头像与帖子插图等公开图片)
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
URL string `gorm:"size:512;not null" json:"url"`
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
Width int `gorm:"not null;default:0" json:"width"`
@@ -601,6 +751,20 @@ type Attachment struct {
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// Blob 内容寻址物理文件:对【最终存储字节】算 SHA-256,同内容多次上传共享一份底层存储,
// 业务行(Attachment/PostAttachment 及未来的聊天文件消息)只引用 hash,按引用计数回收。
// 公开(帖子插图/头像)与私有(付费附件、未来私聊文件)是两个互不可见的命名空间:
// 同字节也各自存一份,避免私有文件借公开链接(/api/media/<id>)被越权读取。
type Blob struct {
Hash string `gorm:"primaryKey;size:64" json:"-"` // 最终存储字节的 SHA-256 hex(图片为转码后的字节)
Public bool `gorm:"primaryKey" json:"-"`
RefCount int64 `gorm:"not null;default:0" json:"-"`
Size int `gorm:"not null;default:0" json:"-"`
MIME string `gorm:"size:128;not null;default:''" json:"-"`
ObjectID string `gorm:"size:96;not null;default:''" json:"-"` // S3 后端的 StoredObject ID;本地后端为空
CreatedAt time.Time `json:"-"`
}
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
type UserBoard struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -672,6 +836,7 @@ type ChatRoomMember struct {
Role string `gorm:"size:16;not null;default:member" json:"role"`
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
MutedUntil *time.Time `json:"muted_until,omitempty"` // 禁言截止时间;nil 表示永久
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -683,17 +848,18 @@ type ChatRoomMember struct {
type ChatMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
SenderID uint `gorm:"index;not null" json:"sender_id"`
SenderID uint `gorm:"index" json:"sender_id"` // 0 表示系统消息(is_system=true)
Content string `gorm:"type:varchar(2000);not null" json:"content"`
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
RecalledAt *time.Time `json:"recalled_at,omitempty"`
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
IsSystem bool `gorm:"not null;default:false" json:"is_system"` // 系统提示(如禁言通知),sender_id=0
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
Sender User `gorm:"foreignKey:SenderID;constraint:-" json:"sender,omitempty"` // constraint:- 禁用 FK,支持 sender_id=0 的系统消息
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
}

View File

@@ -51,7 +51,7 @@ type EmailChallenge struct {
// Every remote object pins the immutable configuration revision that wrote it.
type StoredObject struct {
ID string `gorm:"primaryKey;size:64" json:"id"`
ID string `gorm:"primaryKey;size:96" json:"id"`
ConfigName string `gorm:"size:64;index" json:"-"`
Key string `gorm:"size:512" json:"-"`
MIME string `gorm:"size:128" json:"-"`

View File

@@ -18,18 +18,19 @@ import (
// 事件类型:前后端共享的单层 JSON 协议契约,禁止手拼 JSON / 双重编码
const (
EventHello = "hello" // 建连欢迎帧(含在线用户快照)
EventPong = "pong" // 应用层心跳应答
EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播
EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间
EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID}
EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id}
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
EventHello = "hello" // 建连欢迎帧(含在线用户快照)
EventPong = "pong" // 应用层心跳应答
EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播
EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间
EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID}
EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id}
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
EventChatMemberMuted = "chat:member_muted" // 成员被禁言/解禁,推 chat:{roomID}
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
)
// RoomStaff 管理团队房间(板块管理员及以上)

View File

@@ -3,6 +3,7 @@ package router
import (
"context"
"log"
"net"
"os"
"path/filepath"
"strings"
@@ -18,6 +19,30 @@ import (
"github.com/gin-gonic/gin"
)
// parseTrustedProxies 解析 TRUSTED_PROXIES(逗号分隔的 IP/CIDR/localhost)。
// 容忍条目两侧空白与空项,无法解析的条目跳过并告警:编排环境变量多一个空格
// 不应让整个服务拒绝启动(真实事故:尾随空格致 invalid CIDR 秒退)。
// 全部非法或未配置时返回 nil,退化为「ClientIP 记 TCP 对端」并触发启动警告。
func parseTrustedProxies(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if part == "localhost" {
out = append(out, part)
continue
}
if _, _, err := net.ParseCIDR(part); err != nil && net.ParseIP(part) == nil {
log.Printf("警告: TRUSTED_PROXIES 条目 %q 无法解析,已忽略", part)
continue
}
out = append(out, part)
}
return out
}
// Setup 初始化路由
func Setup(cfg *config.Config) (*gin.Engine, error) {
if cfg.DevMode {
@@ -27,12 +52,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}
r := gin.New()
var proxies []string
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
proxies = strings.Split(v, ",")
} else {
proxies := parseTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
if len(proxies) == 0 {
// 未配置信任代理时 ClientIP 退化为 TCP 直连对端:反代/容器部署会记录内网地址
log.Println("警告: TRUSTED_PROXIES 未配置,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
log.Println("警告: TRUSTED_PROXIES 未配置或无有效条目,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
}
if err := r.SetTrustedProxies(proxies); err != nil {
return nil, err
@@ -55,7 +78,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}))
// 服务
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret).WithDevMode(cfg.DevMode)
boardSvc := service.NewBoardService(model.DB)
settingSvc := service.NewSettingService(model.DB)
// 等级体系快照随进程启动加载(后台保存时经 SetLevels 刷新,无需重启)
@@ -78,10 +101,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
postFileSvc.WithOperations(ops)
pointsSvc := service.NewPointsService(model.DB)
leaderboardSvc := service.NewLeaderboardService(model.DB)
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
WithSetting(settingSvc).
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
snippetSvc := service.NewSnippetService(model.DB)
adminUserSvc := service.NewAdminUserService(model.DB)
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
chatSvc := service.NewChatService(model.DB, notifSvc)
// 导入建号绕过注册链路,显式接线:导入用户同样加入默认全站大厅
legacyImportSvc.WithHallMembership(chatSvc.EnsureDefaultMembership)
badgeSvc := service.NewBadgeService(model.DB).WithNotification(notifSvc)
visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
analyticsSvc := service.NewAnalyticsService(model.DB)
@@ -93,45 +122,51 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
if err := postFileSvc.EnsureDir(); err != nil {
return nil, err
}
if err := librarySvc.EnsureDir(); err != nil {
return nil, err
}
limiter := service.DefaultRateLimiter()
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)
pipeCtx, pipeCancel := context.WithCancel(context.Background())
defer pipeCancel()
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。
// 不用 WithCancel+defer:Setup 返回即 cancel 会让落库协程在启动阶段就退出,事件永远不落库
pipeCtx := context.Background()
go visitSvc.StartFlusher(pipeCtx)
go visitSvc.RunRetention(pipeCtx)
h := &handler.Handlers{
Ops: ops,
Cfg: cfg,
Hub: realtime.NewHub(),
Auth: authSvc,
Board: boardSvc,
Post: postSvc,
Comment: commentSvc,
Like: likeSvc,
Favorite: favoriteSvc,
Follow: followSvc,
Notification: notifSvc,
OverviewSvc: overviewSvc,
Checkin: checkinSvc,
Announcement: announcementSvc,
SitePage: sitePageSvc,
Upload: uploadSvc,
PostFile: postFileSvc,
Points: pointsSvc,
Setting: settingSvc,
AdminUser: adminUserSvc,
LegacyImport: legacyImportSvc,
Moderation: moderationSvc,
Chat: chatSvc,
Visit: visitSvc,
Analytics: analyticsSvc,
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
Ads: adSvc,
Sidebar: service.NewSidebarService(model.DB),
Badge: badgeSvc,
Ops: ops,
Cfg: cfg,
Hub: realtime.NewHub(),
Auth: authSvc,
Board: boardSvc,
Post: postSvc,
Comment: commentSvc,
Like: likeSvc,
Favorite: favoriteSvc,
Follow: followSvc,
Notification: notifSvc,
OverviewSvc: overviewSvc,
Checkin: checkinSvc,
Announcement: announcementSvc,
SitePage: sitePageSvc,
Upload: uploadSvc,
PostFile: postFileSvc,
Points: pointsSvc,
Setting: settingSvc,
AdminUser: adminUserSvc,
LegacyImport: legacyImportSvc,
Moderation: moderationSvc,
Chat: chatSvc,
Visit: visitSvc,
Analytics: analyticsSvc,
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
Ads: adSvc,
Sidebar: service.NewSidebarService(model.DB),
FriendLink: service.NewFriendLinkService(model.DB),
Badge: badgeSvc,
LeaderboardSvc: leaderboardSvc,
LibrarySvc: librarySvc,
SnippetSvc: snippetSvc,
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -152,9 +187,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 挂在 OptionalAuth 之后可顺带拿到登录用户
r.Use(middleware.VisitCapture(visitSvc))
// 上传文件长缓存(必须先于 r.Static 注册;文件名随机不覆盖,可 immutable)
r.Use(middleware.UploadStaticCache())
// 上传文件静态服务(data/uploads → /uploads)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
// 品牌图固定地址(友链等外部引用):按站点设置解析到当前图,换图不变链
r.GET("/api/brand/:slot", h.BrandImage)
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
r.GET("/health", h.Health)
@@ -169,12 +210,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
pubAPI.GET("/overview", h.Overview)
pubAPI.GET("/sidebar", h.GetSidebar)
pubAPI.GET("/friend-links", h.GetFriendLinks)
pubAPI.GET("/posts", h.Posts)
pubAPI.GET("/posts/:id", h.PostDetail)
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
pubAPI.GET("/posts/:id/comments", h.PostComments)
pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation)
pubAPI.GET("/users/:id", h.UserProfile)
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
@@ -186,7 +227,20 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.GET("/leaderboard", h.Leaderboard)
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
// 书库(公开浏览/下载;文件经专用端点消费,不进 /uploads 静态目录)
pubAPI.GET("/library", h.LibraryList)
pubAPI.GET("/library/:slug", h.LibraryDetail)
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
// 代码片段(公开广场/详情/评论/Fork 列表)
pubAPI.GET("/snippets", h.SnippetList)
pubAPI.GET("/snippets/languages", h.SnippetLanguages)
pubAPI.GET("/snippets/stats", h.SnippetStats)
pubAPI.GET("/snippets/:id", h.SnippetDetail)
pubAPI.GET("/snippets/:id/comments", h.SnippetComments)
pubAPI.GET("/snippets/:id/forks", h.SnippetForks)
pubAPI.GET("/site-state", h.SiteState)
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
pubAPI.GET("/img", h.ImageVariant)
pubAPI.GET("/media/:object", h.PublicObject)
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
@@ -209,6 +263,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.GET("/me/login-devices", h.MyLoginDevices)
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
api.PUT("/profile", h.UpdateProfile)
// 友情链接申请(登录用户;提交后进 pending 待审批)
api.POST("/friend-links/apply", h.ApplyFriendLink)
api.GET("/friend-links/mine", h.MyFriendLinks)
api.PUT("/friend-links/:id", h.OwnerUpdateFriendLink)
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
api.PUT("/posts/:id", h.UpdatePost)
api.GET("/posts/:id/history", h.PostEditHistory)
@@ -256,6 +314,21 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit)
// 时间线:从 Git releases 页导入更新日志
api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases)
// 代码片段(登录区)
api.POST("/snippets", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetCreate)
api.GET("/snippets/mine", h.SnippetMine)
api.PUT("/snippets/:id", h.SnippetUpdate)
api.DELETE("/snippets/:id", h.SnippetDelete)
api.POST("/snippets/:id/fork", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetFork)
api.POST("/snippets/:id/copy", h.SnippetCopy)
api.GET("/snippets/:id/history", h.SnippetHistory)
api.POST("/snippets/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentCreate)
api.PUT("/snippets/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentUpdate)
api.DELETE("/snippets/:id/comments/:cid", h.SnippetCommentDelete)
api.GET("/snippets/:id/comments/:cid/history", h.SnippetCommentHistory)
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
@@ -331,20 +404,52 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
// (见下方 msgAPI)
// 站点公告文章管理(管理员及以上)
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
// 渠道内容管理(公告/单页/书库/广告):RequireActor 允许任意登录账号进入,
// 由 RequirePerm 按渠道权限码放行——角色默认管理员及以上,
// 超管/站长亦可将单个渠道授予板块管理员或普通用户(账号级授权)
permAPI := r.Group("/api/admin", authMW.RequireActor(), middleware.CSRFMiddleware())
// 站点公告文章管理(PermAnnouncements)
announceAPI := permAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
announceAPI.GET("/announcements", h.AdminListAnnouncements)
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
announceAPI.GET("/pages", h.AdminListSitePages)
announceAPI.POST("/pages", h.AdminCreateSitePage)
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
// 广告位管理(管理员及以上)
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
// 站点单页管理(PermPages)
pagesAPI := permAPI.Group("", authMW.RequirePerm(service.PermPages))
pagesAPI.GET("/pages", h.AdminListSitePages)
pagesAPI.POST("/pages", h.AdminCreateSitePage)
pagesAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
pagesAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
pagesAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
// 书库管理(PermLibrary)
libraryAPI := permAPI.Group("/library", authMW.RequirePerm(service.PermLibrary))
{
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
libraryAPI.GET("/export", h.AdminExportAllLibrary)
libraryAPI.POST("/import", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibrary)
libraryAPI.GET("/docs/:id/export", h.AdminExportLibraryDoc)
libraryAPI.POST("/docs", h.AdminCreateLibraryDoc)
libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc)
libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc)
libraryAPI.DELETE("/docs/:id/purge", h.AdminPurgeLibraryDoc)
libraryAPI.POST("/docs/:id/files", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminUploadLibraryFile)
libraryAPI.DELETE("/files/:fid", h.AdminDeleteLibraryFile)
// 章节(两级树:章 → 节)
libraryAPI.POST("/docs/:id/sections", h.AdminCreateLibrarySection)
libraryAPI.PUT("/sections/:sid", h.AdminUpdateLibrarySection)
libraryAPI.DELETE("/sections/:sid", h.AdminDeleteLibrarySection)
libraryAPI.POST("/sections/:sid/move", h.AdminMoveLibrarySection)
libraryAPI.POST("/docs/:id/import-md", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdown)
libraryAPI.POST("/docs/:id/import-md-batch", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdownBatch)
libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia)
}
// 广告位管理(PermAds)
adsAPI := permAPI.Group("", authMW.RequirePerm(service.PermAds))
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
adsAPI.GET("/ads", h.AdminListAds)
@@ -375,6 +480,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
// 友情链接管理(超级管理员/站长):CRUD + 审批
friendLinkAPI := staffAPI.Group("/friend-links", authMW.RequirePerm(service.PermSettings))
{
friendLinkAPI.GET("", h.AdminListFriendLinks)
friendLinkAPI.POST("", h.AdminCreateFriendLink)
friendLinkAPI.PUT("/:id", h.AdminUpdateFriendLink)
friendLinkAPI.POST("/:id/status", h.AdminSetFriendLinkStatus)
friendLinkAPI.DELETE("/:id", h.AdminDeleteFriendLink)
}
// 站点设置(超级管理员/站长)
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
@@ -391,6 +506,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand)
staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia)
// 媒体库:全站图片盘点(只读展示)+ 网格缩略图
staffAPI.GET("/media-library", authMW.RequirePerm(service.PermSettings), h.AdminMediaLibrary)
staffAPI.GET("/media-library/thumb", authMW.RequirePerm(service.PermSettings), h.MediaLibraryThumb)
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))
@@ -398,6 +516,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
usersAPI.GET("/users", h.AdminListUsers)
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
usersAPI.GET("/users/:id/perms", h.AdminGetUserPerms)
usersAPI.PUT("/users/:id/perms", h.AdminSetUserPerms)
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
@@ -410,6 +530,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages)
}
// 代码片段管理(任意 staff)
staffAPI.GET("/snippets", h.AdminSnippetList)
staffAPI.DELETE("/snippets/:id", h.AdminSnippetDelete)
staffAPI.PUT("/snippets/:id/visibility", h.AdminSnippetSetVisibility)
// 板块管理(仅站长):增删改、排序、版主授权
boardsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermBoards))
{

View File

@@ -0,0 +1,33 @@
package router
import (
"reflect"
"testing"
)
// TestParseTrustedProxies 覆盖:合法 CIDR/IP/localhost、空白容忍、空项、非法条目跳过
func TestParseTrustedProxies(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{"未配置", "", nil},
{"单个网段", "172.19.0.0/16", []string{"172.19.0.0/16"}},
// 真实事故:尾随空格曾导致 invalid CIDR 整进程退出
{"尾随空格", "172.19.0.0/16 ", []string{"172.19.0.0/16"}},
{"两侧空白与空项", " 172.19.0.0/16 , 10.0.0.5 ,", []string{"172.19.0.0/16", "10.0.0.5"}},
{"单个 IP", "10.0.0.5", []string{"10.0.0.5"}},
{"localhost", "localhost", []string{"localhost"}},
{"非法条目跳过", "172.19.0.0/16,bad-cidr", []string{"172.19.0.0/16"}},
{"全部非法", "bad,,worse", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseTrustedProxies(c.in)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("parseTrustedProxies(%q) = %v, want %v", c.in, got, c.want)
}
})
}
}

View File

@@ -1,6 +1,8 @@
package service
import (
"encoding/json"
"sort"
"strings"
"github.com/freefire/jiang13-bbs/model"
@@ -10,21 +12,119 @@ import (
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
const (
PermUsers = "users" // 用户与权限管理(超管/站长)
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予)
PermPages = "pages" // 单页管理(管理员及以上;可账号级授予)
PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予)
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
PermSettings = "settings" // 站点外观设置(超管/站长)
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
PermBoards = "boards" // 板块管理(仅站长)
)
// GrantablePerms 可账号级授予的渠道权限码(授予者需持有 PermUsers)。
// 管理员及以上角色对这些渠道为角色自带,账号级授予只对板块管理员/普通用户生效。
var GrantablePerms = []string{PermAnnouncements, PermPages, PermLibrary, PermAds}
// ValidPermOverride 权限码是否可被账号级授予
func ValidPermOverride(p string) bool {
for _, g := range GrantablePerms {
if p == g {
return true
}
}
return false
}
// NormalizePermOverrides 白名单过滤 + 去重 + 排序,得到可落库的权限码集
func NormalizePermOverrides(perms []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(perms))
for _, p := range perms {
p = strings.TrimSpace(p)
if p == "" || seen[p] || !ValidPermOverride(p) {
continue
}
seen[p] = true
out = append(out, p)
}
sort.Strings(out)
return out
}
// ParsePermOverrides 解析 users.perm_overrides JSON 列(容错:非法内容视为空)
func ParsePermOverrides(raw string) []string {
if raw == "" {
return []string{}
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return []string{}
}
return NormalizePermOverrides(arr)
}
// SerializePermOverrides 序列化为落库 JSON(空集统一 "[]",避免 NULL/空串分歧)
func SerializePermOverrides(perms []string) string {
norm := NormalizePermOverrides(perms)
b, err := json.Marshal(norm)
if err != nil {
return "[]"
}
return string(b)
}
// roleDefaultPerm 角色对功能点的固有权限(不含账号级授予)
func roleDefaultPerm(role model.Role, p string) bool {
switch p {
case PermBoards:
return role == model.RoleOwner
case PermUsers, PermSettings:
return role == model.RoleSuperAdmin || role == model.RoleOwner
case PermAnnouncements, PermPages, PermLibrary, PermAds:
return model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin)
case PermModeration:
return model.IsStaff(role)
}
return false
}
// EffectivePerms 生效权限码列表(角色默认 ∪ 账号授予 + 消息管理),
// 供 /me 下发,前端据此渲染后台入口/按钮;最终权限仍以 HasPerm 校验为准。
func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages bool) []string {
set := map[string]bool{}
all := append([]string{
PermUsers, PermAnnouncements, PermPages, PermLibrary, PermAds,
PermSettings, PermModeration, PermBoards,
}, GrantablePerms...)
for _, p := range all {
if roleDefaultPerm(role, p) {
set[p] = true
}
}
for _, p := range ParsePermOverrides(permOverridesRaw) {
set[p] = true
}
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
set[PermMessages] = true
}
out := make([]string, 0, len(set))
for p := range set {
out = append(out, p)
}
sort.Strings(out)
return out
}
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
type Actor struct {
ID uint
Username string
Role model.Role
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
}
// IsStaff 是否管理团队成员
@@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool {
return a != nil && model.IsStaff(a.Role)
}
// HasPerm 是否拥有某后台功能点
// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予
func (a *Actor) HasPerm(p string) bool {
if a == nil {
return false
}
switch p {
case PermBoards:
return a.Role == model.RoleOwner
case PermUsers, PermSettings:
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
case PermAnnouncements:
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
case PermModeration:
return a.IsStaff()
case PermMessages:
if roleDefaultPerm(a.Role, p) {
return true
}
if p == PermMessages {
return a.HasSiteMessagePerm()
}
for _, e := range a.ExtraPerms {
if e == p {
return true
}
}
return false
}
@@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
return a.HasPerm(PermUsers)
}
// LoadActor 读取用户实时角色与板块授权
// LoadActor 读取用户实时角色、板块授权与账号级渠道权限
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
var u model.User
if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil {
if err := s.db.Select("id", "username", "role", "can_manage_messages", "perm_overrides").First(&u, id).Error; err != nil {
return nil, err
}
actor := &Actor{
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
CanManageMessages: u.CanManageMessages,
ExtraPerms: ParsePermOverrides(u.PermOverrides),
}
if u.Role == model.RoleBoardAdmin {
var ids []uint

View File

@@ -17,32 +17,33 @@ import (
const (
SettingKeyAdsConfig = "ads_config"
AdMaxTitleRunes = 24
AdMaxNoteRunes = 100
AdMaxImageShow = 5
AdMaxTextShow = 6
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
AdDefaultTitle = "自助推广"
AdMaxTitleRunes = 24
AdMaxNoteRunes = 100
AdMaxImageShow = 5
AdMaxTextShow = 6
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
AdDefaultTitle = "自助推广"
)
var (
ErrAdNotFound = errors.New("广告不存在")
ErrAdForbidden = errors.New("无权操作")
ErrAdInvalid = errors.New("广告参数无效")
ErrAdCaptcha = errors.New("验证码错误或已过期")
ErrAdDisabled = errors.New("自助推广暂未开放")
ErrAdBadPayment = errors.New("请选择有效的支付方式")
ErrAdBadDuration = errors.New("请选择有效的投放时长")
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
ErrAdNotFound = errors.New("广告不存在")
ErrAdForbidden = errors.New("无权操作")
ErrAdInvalid = errors.New("广告参数无效")
ErrAdCaptcha = errors.New("验证码错误或已过期")
ErrAdDisabled = errors.New("自助推广暂未开放")
ErrAdBadPayment = errors.New("请选择有效的支付方式")
ErrAdBadDuration = errors.New("请选择有效的投放时长")
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
)
// AdDurationOption 可购时长档位
// AdDurationOption 可购时长档位(图片 / 文字广告分别计价)
type AdDurationOption struct {
Days int `json:"days"`
Label string `json:"label"`
PriceHint string `json:"price_hint"`
Days int `json:"days"`
Label string `json:"label"`
PriceHint string `json:"price_hint"` // 图片广告价格
PriceHintText string `json:"price_hint_text"` // 文字广告价格
}
// AdPaymentOption 收款方式(展示二维码,人工确认)
@@ -68,11 +69,11 @@ func DefaultAdConfig() AdConfig {
Enabled: true,
PanelTitle: AdDefaultTitle,
Durations: []AdDurationOption{
{Days: 30, Label: "1 个月", PriceHint: ""},
{Days: 60, Label: "2 个月", PriceHint: ""},
{Days: 90, Label: "3 个月", PriceHint: ""},
{Days: 180, Label: "6 个月", PriceHint: ""},
{Days: 365, Label: "12 个月", PriceHint: ""},
{Days: 30, Label: "1 个月", PriceHint: "", PriceHintText: ""},
{Days: 60, Label: "2 个月", PriceHint: "", PriceHintText: ""},
{Days: 90, Label: "3 个月", PriceHint: "", PriceHintText: ""},
{Days: 180, Label: "6 个月", PriceHint: "", PriceHintText: ""},
{Days: 365, Label: "12 个月", PriceHint: "", PriceHintText: ""},
},
Payments: []AdPaymentOption{},
}
@@ -116,6 +117,9 @@ func (s *AdService) loadConfig() (AdConfig, error) {
if strings.TrimSpace(cfg.Durations[i].PriceHint) == "面议" {
cfg.Durations[i].PriceHint = ""
}
if strings.TrimSpace(cfg.Durations[i].PriceHintText) == "面议" {
cfg.Durations[i].PriceHintText = ""
}
}
return cfg, nil
}
@@ -195,7 +199,14 @@ func normalizeAdConfig(in AdConfig) (AdConfig, error) {
if utf8.RuneCountInString(price) > 32 {
return AdConfig{}, ErrAdInvalid
}
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price})
priceText := strings.TrimSpace(d.PriceHintText)
if priceText == "面议" {
priceText = ""
}
if utf8.RuneCountInString(priceText) > 32 {
return AdConfig{}, ErrAdInvalid
}
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price, PriceHintText: priceText})
}
out.Durations = durs

View File

@@ -49,11 +49,11 @@ const (
// AdminContentCounts 当前操作者可见范围内的数量(用于页内 Tab)
type AdminContentCounts struct {
PostsLive int64 `json:"posts_live"`
PostsPending int64 `json:"posts_pending"`
PostsRejected int64 `json:"posts_rejected"`
PostsDeleted int64 `json:"posts_deleted"`
CommentsLive int64 `json:"comments_live"`
PostsLive int64 `json:"posts_live"`
PostsPending int64 `json:"posts_pending"`
PostsRejected int64 `json:"posts_rejected"`
PostsDeleted int64 `json:"posts_deleted"`
CommentsLive int64 `json:"comments_live"`
CommentsPending int64 `json:"comments_pending"`
CommentsRejected int64 `json:"comments_rejected"`
CommentsDeleted int64 `json:"comments_deleted"`
@@ -61,32 +61,34 @@ type AdminContentCounts struct {
// AdminContentPost 后台帖子列表项
type AdminContentPost struct {
ID uint `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
CommentCount int `json:"comment_count"`
BoardID uint `json:"board_id"`
ID uint `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
CommentCount int `json:"comment_count"`
BoardID uint `json:"board_id"`
Board model.Board `json:"board"`
User model.User `json:"user"`
User model.User `json:"user"`
}
// AdminContentComment 后台评论列表项
type AdminContentComment struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
BoardID uint `json:"board_id"`
Content string `json:"content"`
Status string `json:"status"`
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
BoardID uint `json:"board_id"`
Content string `json:"content"`
Status string `json:"status"`
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
Floor uint `json:"floor"` // 所属楼层相对序号
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
Board model.Board `json:"board"`
User model.User `json:"user"`
User model.User `json:"user"`
}
func normalizeAdminStatus(raw string) AdminContentStatus {
@@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
}
items := make([]AdminContentComment, 0, len(rows))
commentIDs := make([]uint, 0, len(rows))
for _, r := range rows {
commentIDs = append(commentIDs, r.ID)
}
anchors := CommentAnchors(s.db, commentIDs)
for _, r := range rows {
it := AdminContentComment{
ID: r.ID,
@@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
Board: boards[r.BoardID],
User: users[r.UserID],
}
if a, ok := anchors[r.ID]; ok {
it.Floor = a.Floor
it.IsRoot = a.IsRoot
}
if r.DeletedAt.Valid {
it.Deleted = true
t := r.DeletedAt.Time

View File

@@ -54,6 +54,7 @@ type AdminUserItem struct {
BoardIDs []uint `json:"board_ids"`
Banned bool `json:"banned"`
CanManageMessages bool `json:"can_manage_messages"`
PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予)
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
Points int `json:"points"`
@@ -71,7 +72,7 @@ type AdminUserItem struct {
// - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复)
type AdminUserSummary struct {
Online int64 `json:"online"`
InCooldown int64 `json:"in_cooldown"`
InCooldown int64 `json:"in_cooldown"`
Active7d int64 `json:"active_7d"`
FailedLogins24h int64 `json:"failed_logins_24h"`
Total int64 `json:"total"`
@@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
BoardIDs: []uint{},
Banned: u.Banned,
CanManageMessages: u.CanManageMessages,
PermOverrides: ParsePermOverrides(u.PermOverrides),
Points: u.Points,
TotalPoints: u.TotalPoints,
Level: u.Level,
@@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
return err
}
}
// 升至管理员及以上后渠道权限变为角色自带,账号级授予清空以免误导
if roleChanged && model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) && u.PermOverrides != "" && u.PermOverrides != "[]" {
if err := tx.Model(&u).Update("perm_overrides", "[]").Error; err != nil {
return err
}
}
return nil
})
if err != nil {
@@ -415,6 +423,53 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
return s.getItem(s.db, targetID)
}
// GetPermOverrides 读取目标账号的渠道权限(PermUsers 持有者可读,供授权弹窗回显)
func (s *AdminUserService) GetPermOverrides(operator *Actor, targetID uint) ([]string, error) {
if operator == nil || !operator.HasPerm(PermUsers) {
return nil, ErrCannotAssignRole
}
var u model.User
if err := s.db.Select("id", "perm_overrides").First(&u, targetID).Error; err != nil {
return nil, err
}
return ParsePermOverrides(u.PermOverrides), nil
}
// SetPermOverrides 授予/撤销账号级渠道权限(公告/单页/书库/广告)。
// 授予者需持有 PermUsers(超管/站长);目标为管理员及以上时渠道为角色自带,
// 授权无意义,强制清空。Actor 每次请求现查 DB,变更即时生效,无需强制下线。
func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perms []string) (*AdminUserItem, error) {
if operator == nil || !operator.HasPerm(PermUsers) {
return nil, ErrCannotAssignRole
}
if operator.ID == targetID {
return nil, ErrAdminSelfAction
}
norm := NormalizePermOverrides(perms)
err := s.db.Transaction(func(tx *gorm.DB) error {
var u model.User
if err := tx.First(&u, targetID).Error; err != nil {
return err
}
if u.Role == model.RoleOwner {
return ErrProtectedOwner
}
// 管理员及以上角色自带全部渠道权限,账号级授予保持为空
if model.RoleLevel(u.Role) >= model.RoleLevel(model.RoleAdmin) {
norm = []string{}
}
raw := SerializePermOverrides(norm)
if u.PermOverrides == raw {
return nil
}
return tx.Model(&u).Update("perm_overrides", raw).Error
})
if err != nil {
return nil, err
}
return s.getItem(s.db, targetID)
}
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
if operator == nil || operator.Role != model.RoleOwner {
@@ -564,12 +619,12 @@ func normalizeAuditPage(page, size int) (int, int) {
// AdminUserAuditProfile 站长查看用户档案摘要
type AdminUserAuditProfile struct {
User AdminUserItem `json:"user"`
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
PublishedPosts int64 `json:"published_posts"`
PublishedComments int64 `json:"published_comments"`
User AdminUserItem `json:"user"`
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
PublishedPosts int64 `json:"published_posts"`
PublishedComments int64 `json:"published_comments"`
}
// AdminAuditPostItem 审计帖子行
@@ -594,6 +649,8 @@ type AdminAuditCommentItem struct {
Deleted bool `json:"deleted"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
Floor uint `json:"floor"`
IsRoot bool `json:"is_root"`
}
// AdminAuditMessageItem 审计聊天消息行
@@ -686,6 +743,11 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
return nil, 0, page, err
}
commentIDs := make([]uint, 0, len(comments))
for _, c := range comments {
commentIDs = append(commentIDs, c.ID)
}
anchors := CommentAnchors(s.db, commentIDs)
items := make([]AdminAuditCommentItem, 0, len(comments))
for _, c := range comments {
it := AdminAuditCommentItem{
@@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
Status: c.Status,
CreatedAt: c.CreatedAt,
}
if a, ok := anchors[c.ID]; ok {
it.Floor = a.Floor
it.IsRoot = a.IsRoot
}
if c.DeletedAt.Valid {
it.Deleted = true
t := c.DeletedAt.Time

View File

@@ -24,10 +24,15 @@ const (
// CSRFHeaderName 前端传递 CSRF token 的 header 名
CSRFHeaderName = "X-CSRF-Token"
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
AccessTokenTTL = 15 * time.Minute
// RefreshTokenTTL refresh token 有效期(长期)
RefreshTokenTTL = 7 * 24 * time.Hour
// AccessTokenTTL access token 有效期。安全性由每请求实时校验
// (token_version + 封禁 + 会话家族活性)保证,吊销即时生效,故取 7 天:
// 登录后 7 天内即使零轮转也保证不掉线,覆盖移动端长期待机场景。
// (15min 短期 TTL 时代待机后必删 cookie,唤醒恢复全靠轮转链,
// 是手机端弹"登录已失效"的主因)
AccessTokenTTL = 7 * 24 * time.Hour
// RefreshTokenTTL refresh token 有效期(每次轮转重置 = 活跃用户滑动续期),
// 30 天内有过访问即持续在线;连续 30 天完全不访问才需重新登录
RefreshTokenTTL = 30 * 24 * time.Hour
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
RefreshRotationGrace = 60 * time.Second
@@ -73,6 +78,7 @@ type AuthService struct {
db *gorm.DB
jwtSecret []byte
encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生)
devMode bool
}
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
@@ -81,15 +87,33 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]}
}
// WithDevMode dev 模式放宽轮转宽限期:开发中 go run / next dev 重启频繁,
// 轮换响应丢失后的重放往往远超 60s 才回来,同链重放不应升级为盗用判定
func (s *AuthService) WithDevMode(dev bool) *AuthService {
s.devMode = dev
return s
}
// rotationGrace 轮转宽限期:dev 放宽(见 RefreshRotationGrace 注释)
func (s *AuthService) rotationGrace() time.Duration {
if s.devMode {
return 15 * time.Minute
}
return RefreshRotationGrace
}
// dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
// 邮箱非空时全库唯一(忽略大小写):后期支持邮箱登录,一邮箱只能对应一个账号
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
return nil, err
}
// 邮箱统一小写归一化,避免大小写变体重复注册
email = strings.ToLower(strings.TrimSpace(email))
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -109,6 +133,16 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
if count > 0 {
return errors.New("用户名已被使用")
}
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
if email != "" {
var emailCount int64
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
return err
}
if emailCount > 0 {
return errors.New("该邮箱已被注册")
}
}
var n int64
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
return err
@@ -133,7 +167,8 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
}
// Login 用户登录,返回 access token + refresh token + user;
// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知)
// kicked 为本次登录因超出会话上限被淘汰的最旧会话数(多会话并存:
// 新登录不踢旧设备,仅超过 maxActiveSessions 时裁剪;>0 供上层广播告知)
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
@@ -265,7 +300,9 @@ var (
var (
ErrRefreshInvalid = errors.New("refresh token 无效")
ErrRefreshExpired = errors.New("refresh token 已过期")
ErrRefreshReused = errors.New("refresh token 存在重用嫌疑")
// errReplayChainDead 宽限期外重放时,token 所在轮转链已无有效头节点
// (家族被整体吊销:设备剔除/超限淘汰/改密/封禁),属真实失效
errReplayChainDead = errors.New("refresh token 轮转链已无有效会话")
)
// hashRefreshToken refresh token 只以 SHA-256 形式落库
@@ -317,7 +354,8 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
}
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。
// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。
// 多会话并存:各设备登录互不影响,新会话构成独立的轮转家族(family_id);
// 返回值 kicked 为因超出会话上限被淘汰的最旧会话行数。
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) {
token := generateRandomToken()
cipherText, err := s.encryptToken(token)
@@ -342,7 +380,7 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui
return "", 0, 0, err
}
rt.FamilyID = rt.ID
kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID)
kicked := s.enforceSessionCap(userID)
return token, rt.FamilyID, kicked, nil
}
@@ -383,7 +421,8 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
// - 宽限期外重放(轮换响应丢失后浏览器补投旧 token):轮转链头节点仍健康时
// 返回头节点 token 对自愈放行;整链已死才是真失效(见 healReplayFromHead)
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
var (
accessToken, newRefresh string
@@ -394,6 +433,11 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
var rt model.RefreshToken
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
// 数据库故障 ≠ 凭据无效:透传给上层按"服务暂不可用"处理,
// 不得据此清除用户 cookie(否则 DB 抖动会变成全员强制重登)
return err
}
outErr = ErrRefreshInvalid
return nil
}
@@ -410,7 +454,7 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
if rt.Revoked {
// 宽限期内、且轮转链指向的新 token 仍健康:并发重放,返回同一对
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= RefreshRotationGrace {
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= s.rotationGrace() {
var next model.RefreshToken
if e := tx.First(&next, rt.RotatedTo).Error; e == nil &&
next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) &&
@@ -423,14 +467,24 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
}
}
}
// 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族
if err := tx.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ?", rt.UserID, false).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
return err
// 宽限期外重放:真实浏览器里最常见成因不是盗用,而是轮换响应丢失
// (RSC 预取被浏览器中止、移动端切后台挂起截断请求、middleware 超时
// 放弃、网络抖动)——后端已完成轮转落库,但 Set-Cookie 没进浏览器
// jar,旧 token 因此变成"已吊销"。若按盗用吊销整个家族,等价于把
// 无辜设备强制下线("登录已失效"弹窗的主要来源)。故沿 rotated_to
// 链找头节点:仍健康则补发头节点 token 对自愈;整链已死才是真失效。
at, plain, healedUser, healErr := s.healReplayFromHead(tx, &rt, now)
if healErr == nil {
accessToken, newRefresh, outUser = at, plain, healedUser
return nil
}
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID)
outErr = ErrRefreshReused
if !errors.Is(healErr, errReplayChainDead) {
// 封禁等账号级错误透传,让前端给出对应告知
outErr = healErr
return nil
}
log.Printf("[auth] refresh 宽限期外重放且轮转链已失效,拒绝 family=%d", sessionFamilyID(rt))
outErr = ErrRefreshInvalid
return nil
}
@@ -489,6 +543,46 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
return accessToken, newRefresh, outUser, nil
}
// healReplayFromHead 宽限期外的同链重放自愈:沿 rotated_to 轮转链走到头节点
// (该会话家族当前生效的 refresh token),头节点仍健康则返回其 token 对——
// 等价于把丢失的轮换响应补投给浏览器。链已断/头已死(家族被整体吊销)时
// 返回 errReplayChainDead;用户被封禁时透传 ErrAccountBanned。
// 安全取舍:放行重放意味着偷到旧 token 者也能追上当前会话,故仅在此处用
// 可用性换安全(HttpOnly cookie 下旧 token 外泄门槛高,且改密/封禁/剔除
// 设备仍可随时终止会话);头部吊销型处置(改密、封禁、剔除、超限淘汰)
// 之后链上无活头节点,自愈自然失效,不会被绕过。
func (s *AuthService) healReplayFromHead(tx *gorm.DB, rt *model.RefreshToken, now time.Time) (string, string, *model.User, error) {
cur := *rt
// 迭代上限防御脏数据成环;正常链长即 7 天寿命内的轮转次数
for i := 0; cur.Revoked && i < 128; i++ {
if cur.RotatedTo == 0 {
return "", "", nil, errReplayChainDead
}
var next model.RefreshToken
if err := tx.First(&next, cur.RotatedTo).Error; err != nil {
return "", "", nil, errReplayChainDead
}
cur = next
}
if cur.Revoked || cur.UserID != rt.UserID || now.After(cur.ExpiresAt) || cur.TokenCipher == "" {
return "", "", nil, errReplayChainDead
}
user, err := s.loadActiveUser(tx, cur.UserID)
if err != nil {
return "", "", nil, err
}
plain, err := s.decryptToken(cur.TokenCipher)
if err != nil {
return "", "", nil, errReplayChainDead
}
at, err := s.generateToken(user, sessionFamilyID(cur))
if err != nil {
return "", "", nil, err
}
log.Printf("[auth] refresh 宽限期外重放,轮转链头节点仍健康,自愈放行 family=%d", sessionFamilyID(cur))
return at, plain, user, nil
}
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
func (s *AuthService) RevokeRefreshToken(token string) error {
if token == "" {
@@ -600,14 +694,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
if nickCount > 0 {
return nil, errors.New("该昵称已被使用")
}
email = strings.TrimSpace(email)
email = strings.ToLower(strings.TrimSpace(email))
if email != "" {
if !strings.Contains(email, "@") || len(email) > 128 {
return nil, errors.New("邮箱格式不正确")
}
// 邮箱唯一性校验(排除自身)
// 邮箱唯一性校验(忽略大小写,排除自身),与注册口径一致
var count int64
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
s.db.Model(&model.User{}).Where("LOWER(email) = ? AND id <> ?", email, userID).Count(&count)
if count > 0 {
return nil, errors.New("该邮箱已被使用")
}

View File

@@ -0,0 +1,223 @@
package service
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"testing"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newBlobTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.Blob{}, &model.Attachment{}, &model.PostAttachment{},
&model.User{}, &model.Post{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func webpFixtureBytes() []byte {
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
src.Set(0, 0, color.RGBA{10, 20, 30, 255})
var buf bytes.Buffer
_ = webpenc.Encode(&buf, src, webpenc.Options{Quality: ImageWebPQuality})
return buf.Bytes()
}
func TestSaveImageBlobDedupAndRefcount(t *testing.T) {
db := newBlobTestDB(t)
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
data := webpFixtureBytes()
att1, err := s.SaveImage(1, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage 1: %v", err)
}
att2, err := s.SaveImage(2, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage 2: %v", err)
}
if att1.URL != att2.URL || att1.BlobHash == "" || att1.BlobHash != att2.BlobHash {
t.Fatalf("identical content should share url/hash: %q vs %q", att1.URL, att2.URL)
}
var blob model.Blob
if err := db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true).Error; err != nil {
t.Fatalf("load blob: %v", err)
}
if blob.RefCount != 2 {
t.Fatalf("refcount want 2, got %d", blob.RefCount)
}
blobPath := filepath.Join(dir, filepath.FromSlash(att1.URL[len("/uploads/"):]))
if fi, err := os.Stat(blobPath); err != nil || fi.IsDir() {
t.Fatalf("shared physical file missing: %v", err)
}
// 删一个引用:文件保留
if err := s.DeleteAttachment(1, att1.ID); err != nil {
t.Fatalf("DeleteAttachment 1: %v", err)
}
db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true)
if blob.RefCount != 1 {
t.Fatalf("refcount want 1, got %d", blob.RefCount)
}
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("blob file should survive while referenced: %v", err)
}
// 最后一个引用删除:文件与 blob 行一起回收
if err := s.DeleteAttachment(2, att2.ID); err != nil {
t.Fatalf("DeleteAttachment 2: %v", err)
}
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
t.Fatalf("blob file should be removed after last release, stat err=%v", err)
}
var n int64
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att1.BlobHash, true).Count(&n)
if n != 0 {
t.Fatalf("blob row should be gone, count=%d", n)
}
}
func TestSaveImageTranscodedJPEGDedup(t *testing.T) {
db := newBlobTestDB(t)
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
src.Set(1, 2, color.RGBA{200, 100, 50, 255})
var in bytes.Buffer
if err := jpeg.Encode(&in, src, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
att1, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage 1: %v", err)
}
att2, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage 2: %v", err)
}
// hash 必须对转码后的字节计算:同源 JPEG 两次上传应命中同一个 WebP blob
if att1.URL != att2.URL || att1.BlobHash != att2.BlobHash {
t.Fatalf("transcoded JPEG should dedup: %q(%s) vs %q(%s)", att1.URL, att1.BlobHash, att2.URL, att2.BlobHash)
}
}
func TestSaveDraftFileBlobDedupAndRefcount(t *testing.T) {
db := newBlobTestDB(t)
root := t.TempDir()
s := NewPostFileService(db, root)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
data := []byte("paid attachment content v1")
a1, err := s.SaveDraftFile(1, "report.pdf", bytes.NewReader(data), 50)
if err != nil {
t.Fatalf("SaveDraftFile 1: %v", err)
}
a2, err := s.SaveDraftFile(2, "another-name.pdf", bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("SaveDraftFile 2: %v", err)
}
if a1.BlobHash == "" || a1.BlobHash != a2.BlobHash || a1.StoredName != a2.StoredName {
t.Fatalf("same bytes should share blob: %+v vs %+v", a1, a2)
}
// 原始文件名仍各自保留
if a1.Name != "report.pdf" || a2.Name != "another-name.pdf" {
t.Fatalf("original names not preserved: %q / %q", a1.Name, a2.Name)
}
blobPath := s.FilePath(a1)
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("shared private blob missing: %v", err)
}
var blob model.Blob
if err := db.First(&blob, "hash = ? AND public = ?", a1.BlobHash, false).Error; err != nil {
t.Fatalf("load private blob: %v", err)
}
if blob.RefCount != 2 {
t.Fatalf("refcount want 2, got %d", blob.RefCount)
}
if err := s.DeleteOwn(1, a1.ID); err != nil {
t.Fatalf("DeleteOwn 1: %v", err)
}
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("private blob should survive with one ref: %v", err)
}
if err := s.DeleteOwn(2, a2.ID); err != nil {
t.Fatalf("DeleteOwn 2: %v", err)
}
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
t.Fatalf("private blob should be removed after last release, stat err=%v", err)
}
}
func TestBlobPublicPrivateVisibilityIsolation(t *testing.T) {
db := newBlobTestDB(t)
root := t.TempDir()
img := NewUploadService(db, filepath.Join(root, "uploads"))
pf := NewPostFileService(db, filepath.Join(root, "private"))
if err := img.EnsureDir(); err != nil {
t.Fatalf("ensure upload dir: %v", err)
}
if err := pf.EnsureDir(); err != nil {
t.Fatalf("ensure private dir: %v", err)
}
data := webpFixtureBytes()
att, err := img.SaveImage(1, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
file, err := pf.SaveDraftFile(1, "x.webp", bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("SaveDraftFile: %v", err)
}
if att.BlobHash != file.BlobHash {
t.Fatalf("test premise: same bytes should produce same hash")
}
// 同字节在公开/私有命名空间各持一条 blob,互不复用,防止越权读取
var pubs, privs int64
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, true).Count(&pubs)
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, false).Count(&privs)
if pubs != 1 || privs != 1 {
t.Fatalf("want separate public/private blobs, pubs=%d privs=%d", pubs, privs)
}
publicPath := filepath.Join(root, "uploads", filepath.FromSlash(att.URL[len("/uploads/"):]))
privatePath := pf.FilePath(file)
if publicPath == privatePath {
t.Fatal("public and private blob must not share a path")
}
if _, err := os.Stat(publicPath); err != nil {
t.Fatalf("public blob missing: %v", err)
}
if _, err := os.Stat(privatePath); err != nil {
t.Fatalf("private blob missing: %v", err)
}
}

View File

@@ -0,0 +1,132 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 内容寻址存储(content-addressed storage):
// 物理文件按【最终存储字节】的 SHA-256 命名,业务行只引用 hash 并各持一份引用计数;
// 同内容重复上传不再重复落盘/传输,最后一个引用删除时才回收物理文件。
// 公开与私有是独立命名空间(见 model.Blob 注释)。
// sha256Hex 计算字节的 SHA-256 hex
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// hashFile 计算文件内容的 SHA-256 hex,整文件流式读取不进内存
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// acquireBlobRef 登记一次 blob 引用:行不存在则创建(计数=1),已存在则计数+1。
// 并发下靠 (hash, public) 主键冲突重试,保证不丢计数。
func acquireBlobRef(db *gorm.DB, hash string, public bool, size int, mimeType, objectID string) error {
for {
var b model.Blob
err := db.Where("hash = ? AND public = ?", hash, public).First(&b).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
b = model.Blob{
Hash: hash,
Public: public,
RefCount: 1,
Size: size,
MIME: mimeType,
ObjectID: objectID,
}
if err := db.Create(&b).Error; err != nil {
if isUniqueConflict(err) {
continue // 并发首建:改为走计数+1
}
return err
}
return nil
}
if err != nil {
return err
}
return db.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", gorm.Expr("ref_count + 1")).Error
}
}
// releaseBlobRef 释放一次 blob 引用。计数归零时删行并返回 last=true 与该行快照
// (快照里的 ObjectID/Hash 供调用方回收物理对象)。行不存在视为幂等成功。
func releaseBlobRef(db *gorm.DB, hash string, public bool) (model.Blob, bool, error) {
var removed model.Blob
var last bool
err := db.Transaction(func(tx *gorm.DB) error {
var b model.Blob
// 行锁:postgres 下 FOR UPDATE 串行化同 blob 的并发释放;sqlite 写事务天然串行
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&b, "hash = ? AND public = ?", hash, public).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if b.RefCount <= 1 {
result := tx.Where("hash = ? AND public = ? AND ref_count <= 1", hash, public).Delete(&model.Blob{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
removed = b
last = true
}
return nil
}
return tx.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", b.RefCount-1).Error
})
if err != nil {
return model.Blob{}, false, err
}
return removed, last, nil
}
// placeLocalBlob 把临时文件原子放到内容寻址目标路径:
// 目标已存在说明同内容已落盘(字节必然一致),直接丢弃源文件;否则整体 rename。
// src 与 dst 同一路径时不做操作。
func placeLocalBlob(src, dst string) error {
if abs, err := filepath.Abs(src); err == nil {
if dstAbs, err := filepath.Abs(dst); err == nil && abs == dstAbs {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if _, err := os.Stat(dst); err == nil {
if src != dst {
_ = os.Remove(src)
}
return nil
}
if err := os.Rename(src, dst); err != nil {
_ = os.Remove(src)
return err
}
return nil
}

View File

@@ -24,12 +24,14 @@ type BoardWithCount struct {
PostCount int64 `json:"post_count"`
}
// BoardModerator 板块管理员公开资料
// BoardModerator 板块管理员公开资料(含等级与颁发的徽章)
type BoardModerator struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Level int `json:"level"` // AfterFind 按 TotalPoints 计算
Badges []model.UserBadge `json:"badges,omitempty"` // 管理员颁发的徽章(Preload("Badges.Badge") 填充)
}
// BoardSidebarStats 板级统计(不含在线)
@@ -432,13 +434,14 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
return nil, err
}
// 板块管理员:仅 board_admin 且授权本板、未封禁
// 板块管理员:仅 board_admin 且授权本板、未封禁(等级由 AfterFind 算,徽章随 Preload 带)
var mods []model.User
if err := s.db.Table("users").
Select("users.id, users.username, users.nickname, users.avatar").
if err := s.db.Model(&model.User{}).
Select("users.id, users.username, users.nickname, users.avatar, users.total_points").
Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false).
Order("users.id ASC").
Preload("Badges.Badge").
Find(&mods).Error; err != nil {
return nil, err
}
@@ -448,6 +451,8 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
Level: u.Level,
Badges: u.Badges,
})
}

View File

@@ -0,0 +1,69 @@
package service
import (
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// 板块右栏聚合:管理员应携带等级(AfterFind 按累计积分计算)与颁发的徽章
func TestBoardSidebarModeratorsCarryLevelAndBadges(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Board{}, &model.UserBoard{},
&model.Post{}, &model.Comment{}, &model.Badge{}, &model.UserBadge{}); err != nil {
t.Fatalf("migrate: %v", err)
}
board := model.Board{Name: "综合讨论"}
if err := db.Create(&board).Error; err != nil {
t.Fatalf("seed board: %v", err)
}
// 高积分管理员 + 低积分管理员 + 普通用户(不入列)
modHigh := model.User{Username: "mod_high", Password: "x", Role: model.RoleBoardAdmin, TotalPoints: 10000}
modLow := model.User{Username: "mod_low", Password: "x", Role: model.RoleBoardAdmin}
plain := model.User{Username: "plain", Password: "x"}
for _, u := range []*model.User{&modHigh, &modLow, &plain} {
if err := db.Create(u).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
}
for _, uid := range []uint{modHigh.ID, modLow.ID, plain.ID} {
if err := db.Create(&model.UserBoard{UserID: uid, BoardID: board.ID}).Error; err != nil {
t.Fatalf("seed user_board: %v", err)
}
}
badge := model.Badge{Name: "骨灰玩家", Icon: "medal", Color: "gold"}
if err := db.Create(&badge).Error; err != nil {
t.Fatalf("seed badge: %v", err)
}
if err := db.Create(&model.UserBadge{BadgeID: badge.ID, UserID: modHigh.ID, AwardedBy: 1}).Error; err != nil {
t.Fatalf("seed user_badge: %v", err)
}
s := NewBoardService(db)
data, err := s.Sidebar(board.ID)
if err != nil {
t.Fatalf("Sidebar: %v", err)
}
if len(data.Moderators) != 2 {
t.Fatalf("应只含 2 名 board_admin,got %d", len(data.Moderators))
}
first := data.Moderators[0]
if first.Username != "mod_high" {
t.Fatalf("排序错位: %+v", first)
}
if first.Level <= 1 {
t.Fatalf("高积分管理员等级应 >1,got %d", first.Level)
}
if len(first.Badges) != 1 || first.Badges[0].Badge.Name != "骨灰玩家" {
t.Fatalf("徽章未随行加载: %+v", first.Badges)
}
if data.Moderators[1].Level != 1 || len(data.Moderators[1].Badges) != 0 {
t.Fatalf("低积分管理员应为 Lv.1 无徽章: %+v", data.Moderators[1])
}
}

View File

@@ -9,21 +9,28 @@ import (
// 站点品牌与页脚(公开设置,缺省为空)
const (
SettingKeySiteWordmark = "site_wordmark"
SettingKeySiteSlogan = "site_slogan"
SettingKeySiteKeywords = "site_keywords"
SettingKeyLogoLightURL = "logo_light_url"
SettingKeyLogoDarkURL = "logo_dark_url"
SettingKeyFaviconURL = "favicon_url"
SettingKeyFooterLinks = "footer_links"
SettingKeyBrandMark = "brand_mark"
SettingKeyBrandLogoSize = "brand_logo_size"
SettingKeyBrandLogoFit = "brand_logo_fit"
SettingKeySiteWordmark = "site_wordmark"
SettingKeySiteSlogan = "site_slogan"
SettingKeySiteKeywords = "site_keywords"
SettingKeyLogoLightURL = "logo_light_url"
SettingKeyLogoDarkURL = "logo_dark_url"
SettingKeyFaviconURL = "favicon_url"
SettingKeyFooterLinks = "footer_links"
SettingKeyBrandMark = "brand_mark"
SettingKeyBrandLogoSize = "brand_logo_size"
SettingKeyBrandLogoFit = "brand_logo_fit"
SettingKeyBrandSloganVisible = "brand_slogan_visible"
BrandSlotLight = "logo_light"
BrandSlotDark = "logo_dark"
BrandSlotFavicon = "favicon"
// 固定对外地址:外部(友链等)引用这些路径,站点换图后地址不变。
// 实际文件仍存随机名,请求时按站点设置解析到当前图。
BrandAliasPathLight = "/api/brand/logo-light"
BrandAliasPathDark = "/api/brand/logo-dark"
BrandAliasPathFavicon = "/api/brand/favicon"
BrandMarkImageText = "image_text"
BrandMarkImage = "image"
BrandMarkText = "text"
@@ -37,6 +44,8 @@ const (
BrandLogoSize3x1 = "3x1"
BrandLogoSize4x1 = "4x1"
BrandLogoSize169 = "16x9"
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
BrandLogoSizeAuto = "auto"
BrandLogoFitCover = "cover"
BrandLogoFitContain = "contain"
@@ -108,7 +117,7 @@ func NormalizeBrandLogoSize(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", BrandLogoSizeSQ:
return BrandLogoSizeSQ, true
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169, BrandLogoSizeAuto:
return strings.TrimSpace(raw), true
default:
return "", false
@@ -361,6 +370,15 @@ func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
out.BrandLogoFit = f
}
}
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
if err != nil {
return err
}
if found {
out.BrandSloganVisible = sv == "true"
} else {
out.BrandSloganVisible = true
}
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
if err != nil {
return err
@@ -477,6 +495,14 @@ func (s *SettingService) SetBrandLogoFit(raw string) error {
return s.putValue(SettingKeyBrandLogoFit, v)
}
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
if visible {
return s.deleteKey(SettingKeyBrandSloganVisible)
}
return s.putValue(SettingKeyBrandSloganVisible, "false")
}
func (s *SettingService) setBrandURL(key, slot, url string) error {
u, ok := NormalizeBrandURL(slot, url)
if !ok {

View File

@@ -183,6 +183,22 @@ func (s *UploadService) BrandFileExists(url string) bool {
return err == nil && !info.IsDir()
}
// OpenBrandFile 打开品牌图文件供对外服务;未设置或文件缺失返回 os.ErrNotExist
func (s *UploadService) OpenBrandFile(url string) (*os.File, error) {
if BrandRelPath(url) == "" {
return nil, os.ErrNotExist
}
abs, ok := s.safeUploadPath(url)
if !ok {
return nil, os.ErrNotExist
}
f, err := os.Open(abs)
if err != nil {
return nil, os.ErrNotExist
}
return f, nil
}
// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {

View File

@@ -1,6 +1,34 @@
package service
import "testing"
import (
"errors"
"os"
"testing"
)
func TestOpenBrandFile(t *testing.T) {
s, dir := newMediaLibraryService(t)
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
if _, err := s.OpenBrandFile(png); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing file want ErrNotExist, got %v", err)
}
writeMediaFile(t, dir, "brand/0123456789abcdef0123456789abcdef.png", []byte("png"))
f, err := s.OpenBrandFile(png)
if err != nil {
t.Fatal(err)
}
f.Close()
for _, bad := range []string{
"",
"/uploads/images/0123456789abcdef0123456789abcdef.png",
"/uploads/brand/../avatars/x.png",
"http://evil.test/x.png",
} {
if _, err := s.OpenBrandFile(bad); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("should reject %q, got %v", bad, err)
}
}
}
func TestNormalizeBrandURL(t *testing.T) {
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"

View File

@@ -75,8 +75,8 @@ type RoomView struct {
Joined bool `json:"joined"`
MyRole string `json:"my_role"`
UnreadCount int64 `json:"unread_count"`
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
Peer *model.User `json:"peer,omitempty"` // 私聊对方(仅 direct)
}
@@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR
if err != nil {
return nil, err
}
// 过期禁言自动解除:惰性清理,不依赖定时任务
if m.Muted && m.MutedUntil != nil && m.MutedUntil.Before(time.Now()) {
m.Muted = false
m.MutedUntil = nil
_ = tx.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id = ?", roomID, userID).
Select("muted", "muted_until").
Updates(map[string]interface{}{"muted": false, "muted_until": nil}).Error
}
return &m, nil
}
@@ -122,7 +131,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
return s.membership(s.db, roomID, userID)
}
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送与系统提示)
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
out := make(map[uint]int64)
if userID == 0 || len(roomIDs) == 0 {
@@ -132,7 +141,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
s.db.Table("chat_messages AS m").
Select("m.room_id AS room_id, count(*) AS cnt").
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", roomIDs, userID, false).
Group("m.room_id").
Scan(&rows)
for _, r := range rows {
@@ -397,7 +406,9 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
var existing model.ChatRoom
err := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error
if err == nil {
_ = s.ensureMember(existing.ID, meID)
// 关闭私聊会硬删成员行、丢失已读水位;主动重开视为已读历史,
// 否则重建行 last_read=0 会把全部历史消息重算成未读(列表徽标闪现)
_ = s.ensureMember(existing.ID, meID, existing.LastMessageID)
_ = s.ensureMember(existing.ID, peerID)
return s.RoomDetail(meID, existing.ID, false)
}
@@ -431,7 +442,8 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
return s.RoomDetail(meID, room.ID, false)
}
func (s *ChatService) ensureMember(roomID, userID uint) error {
// ensureMember 保证成员行存在;重建(如私聊关闭后重开)时可选恢复已读水位
func (s *ChatService) ensureMember(roomID, userID uint, readWatermark ...uint) error {
_, err := s.membership(s.db, roomID, userID)
if err == nil {
return nil
@@ -440,6 +452,9 @@ func (s *ChatService) ensureMember(roomID, userID uint) error {
return err
}
m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember}
if len(readWatermark) > 0 {
m.LastReadMessageID = readWatermark[0]
}
if e := s.db.Create(&m).Error; e != nil {
return e
}
@@ -752,44 +767,96 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
return s.removeMember(roomID, targetID)
}
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
// DisplayName 取用户群聊展示名(昵称优先,退回用户名);用户不存在/封禁时返回空串
func (s *ChatService) DisplayName(userID uint) string {
var u model.User
if err := s.db.Select("id", "nickname", "username").First(&u, userID).Error; err != nil {
return ""
}
if u.Nickname != "" {
return u.Nickname
}
return u.Username
}
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
// 失败返回 nil + error。
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
msg := &model.ChatMessage{
RoomID: roomID,
SenderID: 0,
Content: content,
IsSystem: true,
CreatedAt: time.Now(),
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(msg).Error; err != nil {
return err
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
Update("updated_at", time.Now()).Error
})
if err != nil {
return nil, err
}
return msg, nil
}
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号。
// duration 为禁言时长;0 表示永久禁言;muted=false 时忽略 duration 并清空 muted_until。
// 返回目标用户昵称(供广播系统提示使用)。
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted bool, duration time.Duration, oversee bool) (string, error) {
room, err := s.getRoom(roomID)
if err != nil {
return err
return "", err
}
if room.RoomType == model.ChatRoomTypeDirect {
return ErrChatOwnerOnly
return "", ErrChatOwnerOnly
}
target, err := s.membership(s.db, roomID, targetID)
if err != nil {
return err
return "", err
}
if target.Role == model.ChatRoleOwner {
return ErrChatCannotMuteOwner
return "", ErrChatCannotMuteOwner
}
var targetUser model.User
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
return ErrChatUserGone
if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil {
return "", ErrChatUserGone
}
if targetUser.Role == model.RoleOwner {
return ErrChatCannotMuteOwner
return "", ErrChatCannotMuteOwner
}
if !oversee {
op, err := s.membership(s.db, roomID, operatorID)
if err != nil {
return err
return "", err
}
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
return ErrChatMuteDenied
return "", ErrChatMuteDenied
}
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
return ErrChatMuteDenied
return "", ErrChatMuteDenied
}
}
return s.db.Model(&model.ChatRoomMember{}).
var mutedUntil *time.Time
if muted && duration > 0 {
t := time.Now().Add(duration)
mutedUntil = &t
}
if err := s.db.Model(&model.ChatRoomMember{}).
Where("room_id = ? AND user_id = ?", roomID, targetID).
Select("muted").Update("muted", muted).Error
Select("muted", "muted_until").
Updates(map[string]interface{}{"muted": muted, "muted_until": mutedUntil}).Error; err != nil {
return "", err
}
targetName := targetUser.Nickname
if targetName == "" {
targetName = targetUser.Username
}
return targetName, nil
}
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
@@ -964,7 +1031,7 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
if err != nil {
return nil, nil, err
}
if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil {
if err := s.db.Preload("Sender").Preload("Sender.Badges.Badge").First(msg, msg.ID).Error; err != nil {
return nil, nil, err
}
for _, uid := range mentionIDs {
@@ -1053,7 +1120,8 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
q = q.Where("id < ?", beforeID)
}
var msgs []model.ChatMessage
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil {
// Sender.Badges.Badge 嵌套预加载:消息气泡需展示发送者等级与徽章(与成员列表一致)
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").Find(&msgs).Error; err != nil {
return nil, false, err
}
hasMore := len(msgs) > size
@@ -1082,7 +1150,7 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
return nil, false, err
}
var msg model.ChatMessage
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).Preload("Sender").First(&msg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, ErrChatMsgNotFound
}
@@ -1091,6 +1159,10 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
if msg.RecalledAt != nil {
return &msg, false, nil // 幂等
}
// 站长的消息禁止群主/群管/站点消息管撤回(仅全站监管可撤回)
if msg.Sender.Role == model.RoleOwner && !oversee {
return nil, false, ErrChatRecallDenied
}
if msg.SenderID == operatorID {
if !oversee {
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
@@ -1109,7 +1181,17 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
}
msg.RecalledAt = &now
msg.RecalledBy = operatorID
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
// 撤回的是房间当前预览消息时,回退 last_message_id 到最近一条未撤回的非系统消息:
// 撤回不在会话列表留下「已撤回」预览,也不让房间因此停留在列表前排,避免被当成新消息提醒。
var prevID uint
_ = s.db.Model(&model.ChatMessage{}).
Where("room_id = ? AND deleted_at IS NULL AND recalled_at IS NULL AND is_system = ?", roomID, false).
Order("id DESC").Limit(1).
Select("id").Row().Scan(&prevID)
_ = s.db.Model(&model.ChatRoom{}).
Where("id = ? AND last_message_id = ?", roomID, msg.ID).
Update("last_message_id", prevID).Error
_ = s.db.Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").First(&msg, msg.ID)
return &msg, true, nil
}
@@ -1289,13 +1371,13 @@ func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
Update("last_read_message_id", messageID).Error
}
// UnreadSummary 当前用户所有群的未读明细与总数
// UnreadSummary 当前用户所有群的未读明细与总数(系统提示消息不计入)
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
var rows []UnreadRoom
err := s.db.Table("chat_messages AS m").
Select("m.room_id AS room_id, count(*) AS cnt").
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", userID, false).
Group("m.room_id").
Scan(&rows).Error
if err != nil {

View File

@@ -44,7 +44,7 @@ func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool {
// CommentService 评论服务
type CommentService struct {
db *gorm.DB
db *gorm.DB
setting *SettingService
}
@@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int {
return FloorNumber(s.db, postID, commentID)
}
// CommentAnchor 评论在所属帖子内的相对锚点:
// Floor=楼层号(含软删/待审占位,口径同 FloorNumber);IsRoot=true 为主楼,
// false 为楼中楼(锚点需附带评论 ID 才能精确定位具体回复)。
type CommentAnchor struct {
Floor uint `json:"floor"`
IsRoot bool `json:"is_root"`
}
// CommentAnchors 批量查询评论锚点(两条 SQL,避免逐行 N+1);
// 评论或其主楼已不存在(如整串被彻底删除)时不出现在结果中。
func CommentAnchors(db *gorm.DB, ids []uint) map[uint]CommentAnchor {
out := make(map[uint]CommentAnchor, len(ids))
if db == nil || len(ids) == 0 {
return out
}
// Unscoped:软删/待审评论仍占楼层,与 FloorNumber 同口径
var targets []model.Comment
if err := db.Unscoped().
Select("id", "post_id", "parent_id", "root_id", "created_at").
Where("id IN ?", ids).Find(&targets).Error; err != nil {
return out
}
rootIDSet := make(map[uint]struct{}, len(targets))
for _, c := range targets {
rid := c.ID
if c.ParentID != nil && c.RootID != nil {
rid = *c.RootID
}
rootIDSet[rid] = struct{}{}
}
rootIDs := make([]uint, 0, len(rootIDSet))
for rid := range rootIDSet {
rootIDs = append(rootIDs, rid)
}
// 每个主楼之前(按 created_at/id 升序)的主楼数即楼层号 - 1
type aheadRow struct {
RootID uint
Ahead int64
}
var aheadRows []aheadRow
if err := db.Unscoped().Table("comments AS f").
Select(`f.id AS root_id,
(SELECT COUNT(*) FROM comments c
WHERE c.post_id = f.post_id AND c.parent_id IS NULL
AND (c.created_at < f.created_at
OR (c.created_at = f.created_at AND c.id < f.id))) AS ahead`).
Where("f.id IN ? AND f.parent_id IS NULL", rootIDs).
Scan(&aheadRows).Error; err != nil {
return out
}
floorByRoot := make(map[uint]uint, len(aheadRows))
for _, r := range aheadRows {
floorByRoot[r.RootID] = uint(r.Ahead) + 1
}
for _, c := range targets {
isRoot := c.ParentID == nil
rid := c.ID
if !isRoot && c.RootID != nil {
rid = *c.RootID
}
floor := floorByRoot[rid]
if floor == 0 {
continue
}
out[c.ID] = CommentAnchor{Floor: floor, IsRoot: isRoot}
}
return out
}
// AnchorsByCommentIDs 见包级 CommentAnchors
func (s *CommentService) AnchorsByCommentIDs(ids []uint) map[uint]CommentAnchor {
return CommentAnchors(s.db, ids)
}
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
@@ -442,6 +516,8 @@ type UserCommentItem struct {
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Content string `json:"content"`
Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层)
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}]
CreatedAt time.Time `json:"created_at"`
}
@@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
if items == nil {
items = []UserCommentItem{}
}
ids := make([]uint, 0, len(items))
for _, it := range items {
ids = append(ids, it.ID)
}
anchors := s.AnchorsByCommentIDs(ids)
for i := range items {
if a, ok := anchors[items[i].ID]; ok {
items[i].Floor = a.Floor
items[i].IsRoot = a.IsRoot
}
}
return items, total, nil
}

View File

@@ -9,7 +9,7 @@ import (
// FollowService 关注服务(仅用户;关注时给对方发通知)
type FollowService struct {
db *gorm.DB
db *gorm.DB
notif *NotificationService
}

View File

@@ -0,0 +1,305 @@
package service
import (
"errors"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 友情链接状态
const (
FriendLinkStatusPending = "pending"
FriendLinkStatusApproved = "approved"
FriendLinkStatusRejected = "rejected"
)
// 字段长度上限
const (
friendLinkMaxName = 64
friendLinkMaxURL = 512
friendLinkMaxDescription = 255
friendLinkMaxLogoURL = 512
friendLinkMaxCategory = 32
friendLinkMaxReciprocal = 512
friendLinkMaxReject = 255
)
var (
// ErrFriendLinkInvalid 参数校验失败
ErrFriendLinkInvalid = errors.New("友情链接参数无效")
// ErrFriendLinkNotFound 记录不存在
ErrFriendLinkNotFound = errors.New("友情链接不存在")
)
// FriendLinkInput 申请/新建/更新共用入参(指针区分「未传」与「显式清空」由调用方决定)
type FriendLinkInput struct {
Name string `json:"name"`
URL string `json:"url"`
Description string `json:"description"`
LogoURL string `json:"logo_url"`
Category string `json:"category"`
ReciprocalURL string `json:"reciprocal_url"`
}
// FriendLinkService 友情链接申请 + 后台 CRUD/审批
type FriendLinkService struct {
db *gorm.DB
}
func NewFriendLinkService(db *gorm.DB) *FriendLinkService {
return &FriendLinkService{db: db}
}
// validateInput 校验并归一化入参;返回归一化后的值
func validateFriendLinkInput(in FriendLinkInput) (FriendLinkInput, error) {
name := strings.TrimSpace(in.Name)
if name == "" || utf8.RuneCountInString(name) > friendLinkMaxName {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
rawURL := strings.TrimSpace(in.URL)
if rawURL == "" || len(rawURL) > friendLinkMaxURL {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
if u, err := url.Parse(rawURL); err != nil || u.Scheme == "" || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https") {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
desc := strings.TrimSpace(in.Description)
if utf8.RuneCountInString(desc) > friendLinkMaxDescription {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
logo := strings.TrimSpace(in.LogoURL)
if len(logo) > friendLinkMaxLogoURL {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
if logo != "" {
if u, err := url.Parse(logo); err != nil || u.Scheme == "" || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https") {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
}
category := strings.TrimSpace(in.Category)
if utf8.RuneCountInString(category) > friendLinkMaxCategory {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
reciprocal := strings.TrimSpace(in.ReciprocalURL)
if len(reciprocal) > friendLinkMaxReciprocal {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
if reciprocal != "" {
if u, err := url.Parse(reciprocal); err != nil || u.Scheme == "" || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https") {
return FriendLinkInput{}, ErrFriendLinkInvalid
}
}
return FriendLinkInput{
Name: name, URL: rawURL, Description: desc, LogoURL: logo,
Category: category, ReciprocalURL: reciprocal,
}, nil
}
// ListApproved 前台公开列表:仅 approved,按 sort_order ASC, id ASC
func (s *FriendLinkService) ListApproved() ([]model.FriendLink, error) {
var list []model.FriendLink
err := s.db.Where("status = ?", FriendLinkStatusApproved).
Order("sort_order ASC, id ASC").Find(&list).Error
if list == nil {
list = []model.FriendLink{}
}
return list, err
}
// ListAll 后台全量列表(含 pending/rejected),按 status, sort_order, id
func (s *FriendLinkService) ListAll() ([]model.FriendLink, error) {
var list []model.FriendLink
err := s.db.Order("status ASC, sort_order ASC, id ASC").Find(&list).Error
if list == nil {
list = []model.FriendLink{}
}
return list, err
}
// ListMine 申请人本人提交的全部申请(含待审/拒绝),按提交时间倒序
func (s *FriendLinkService) ListMine(userID uint) ([]model.FriendLink, error) {
var list []model.FriendLink
err := s.db.Where("applicant_user_id = ?", userID).
Order("created_at DESC, id DESC").Find(&list).Error
if list == nil {
list = []model.FriendLink{}
}
return list, err
}
// OwnerUpdate 申请人修改自己的申请(名称/链接/回链/LOGO);非待审状态的记录修改后回退待审
func (s *FriendLinkService) OwnerUpdate(id, userID uint, in FriendLinkInput) error {
norm, err := validateFriendLinkInput(in)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
var fl model.FriendLink
if err := tx.First(&fl, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFriendLinkNotFound
}
return err
}
// 仅申请人本人可改;他人访问统一按不存在处理,不暴露记录存在性
if fl.ApplicantUserID == nil || *fl.ApplicantUserID != userID {
return ErrFriendLinkNotFound
}
updates := map[string]interface{}{
"name": norm.Name,
"url": norm.URL,
"logo_url": norm.LogoURL,
"reciprocal_url": norm.ReciprocalURL,
"updated_at": time.Now(),
}
if fl.Status != FriendLinkStatusPending {
updates["status"] = FriendLinkStatusPending
updates["reject_reason"] = ""
}
return tx.Model(&fl).Updates(updates).Error
})
}
// nextSortOrder 取当前 approved 记录中最大 sort_order + 1(新建/审批通过时使用)
func (s *FriendLinkService) nextSortOrder() (int, error) {
var maxOrder int
err := s.db.Model(&model.FriendLink{}).
Where("status = ?", FriendLinkStatusApproved).
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder)
if err != nil {
return 0, err
}
return maxOrder + 1, nil
}
// Apply 用户申请:status=pending
func (s *FriendLinkService) Apply(in FriendLinkInput, applicantUserID uint) (*model.FriendLink, error) {
norm, err := validateFriendLinkInput(in)
if err != nil {
return nil, err
}
uid := applicantUserID
now := time.Now()
fl := &model.FriendLink{
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
Status: FriendLinkStatusPending, SortOrder: 0,
ApplicantUserID: &uid, CreatedAt: now, UpdatedAt: now,
}
if err := s.db.Create(fl).Error; err != nil {
return nil, err
}
return fl, nil
}
// AdminCreate 站长直接新建:status=approved,自动分配 sort_order
func (s *FriendLinkService) AdminCreate(in FriendLinkInput) (*model.FriendLink, error) {
norm, err := validateFriendLinkInput(in)
if err != nil {
return nil, err
}
order, err := s.nextSortOrder()
if err != nil {
return nil, err
}
now := time.Now()
fl := &model.FriendLink{
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
Status: FriendLinkStatusApproved, SortOrder: order,
CreatedAt: now, UpdatedAt: now,
}
if err := s.db.Create(fl).Error; err != nil {
return nil, err
}
return fl, nil
}
// AdminUpdate 全量更新内容(不改 status/sort_order/applicant)
func (s *FriendLinkService) AdminUpdate(id uint, in FriendLinkInput) error {
norm, err := validateFriendLinkInput(in)
if err != nil {
return err
}
res := s.db.Model(&model.FriendLink{}).Where("id = ?", id).
Updates(map[string]interface{}{
"name": norm.Name,
"url": norm.URL,
"description": norm.Description,
"logo_url": norm.LogoURL,
"category": norm.Category,
"reciprocal_url": norm.ReciprocalURL,
"updated_at": time.Now(),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrFriendLinkNotFound
}
return nil
}
// AdminSetStatus 审批:approved 时自动补 sort_order;rejected 时写 reject_reason
func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string) error {
status = strings.TrimSpace(status)
if status != FriendLinkStatusApproved && status != FriendLinkStatusRejected &&
status != FriendLinkStatusPending {
return ErrFriendLinkInvalid
}
if status == FriendLinkStatusRejected && utf8.RuneCountInString(rejectReason) > friendLinkMaxReject {
return ErrFriendLinkInvalid
}
return s.db.Transaction(func(tx *gorm.DB) error {
var fl model.FriendLink
if err := tx.First(&fl, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFriendLinkNotFound
}
return err
}
updates := map[string]interface{}{
"status": status,
"updated_at": time.Now(),
"reject_reason": "",
}
switch status {
case FriendLinkStatusApproved:
if fl.SortOrder == 0 {
var maxOrder int
if err := tx.Model(&model.FriendLink{}).
Where("status = ? AND id <> ?", FriendLinkStatusApproved, id).
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder); err != nil {
return err
}
updates["sort_order"] = maxOrder + 1
}
case FriendLinkStatusRejected:
updates["reject_reason"] = rejectReason
case FriendLinkStatusPending:
// 切回 pending 视为下线,不动 sort_order
}
return tx.Model(&fl).Updates(updates).Error
})
}
// AdminDelete 软删除
func (s *FriendLinkService) AdminDelete(id uint) error {
res := s.db.Delete(&model.FriendLink{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrFriendLinkNotFound
}
return nil
}

View File

@@ -0,0 +1,191 @@
package service
import (
"encoding/json"
"strings"
"unicode/utf8"
)
// 桌面端 Header 主导航(品牌名右侧的菜单)。
// 配置为有序数组,元素顺序即展示顺序;内置项只存 key(文案/图标/地址由前端注册表派生,
// 支持伪静态风格),自定义项存文案与地址。后续新增内置页面只需在前后端白名单各注册一项。
const (
// SettingKeyHeaderNav 缺行/非法=内置默认三项;显式空数组 [] 表示全部隐藏
SettingKeyHeaderNav = "header_nav"
HeaderNavItemBuiltin = "builtin"
HeaderNavItemCustom = "custom"
// MaxHeaderNavItems 菜单项总数上限(内置 + 自定义)
MaxHeaderNavItems = 8
// MaxHeaderNavLabelRunes 自定义菜单文案长度上限
MaxHeaderNavLabelRunes = 16
)
// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序);
// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library", "snippets"}
// HeaderNavItem 顶部导航菜单项:
//
// builtin:Key 取白名单,Label/URL 留空,地址与高亮由前端按 key 派生;
// custom:管理员自定义,Label 必填,URL 为站内路径或 http(s) 外链,NewTab 控制打开方式。
type HeaderNavItem struct {
Type string `json:"type"`
Key string `json:"key,omitempty"`
Label string `json:"label,omitempty"`
URL string `json:"url,omitempty"`
NewTab bool `json:"new_tab,omitempty"`
}
// DefaultHeaderNav 内置默认三项(缺行时回落)
func DefaultHeaderNav() []HeaderNavItem {
out := make([]HeaderNavItem, 0, len(HeaderNavBuiltinKeys))
for _, k := range HeaderNavBuiltinKeys {
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
}
return out
}
func headerNavBuiltinAllowed(k string) bool {
for _, valid := range HeaderNavBuiltinKeys {
if k == valid {
return true
}
}
return false
}
// headerNavItemIn 持久化读取用:NewTab 用指针区分「未指定(外链默认新开)」与显式 false
type headerNavItemIn struct {
Type string `json:"type"`
Key string `json:"key"`
Label string `json:"label"`
URL string `json:"url"`
NewTab *bool `json:"new_tab"`
}
// NormalizeHeaderNav 保存前严格校验:类型/内置 key 非法、自定义文案或地址非法直接报错;
// 内置项重复去重(首次出现为准,与 mobile_tabs 一致)
func NormalizeHeaderNav(raw []HeaderNavItem) ([]HeaderNavItem, error) {
if len(raw) > MaxHeaderNavItems {
return nil, ErrInvalidSiteSetting
}
out := make([]HeaderNavItem, 0, len(raw))
seenBuiltin := make(map[string]struct{}, len(raw))
for _, item := range raw {
switch strings.TrimSpace(item.Type) {
case HeaderNavItemBuiltin:
k := strings.TrimSpace(strings.ToLower(item.Key))
if !headerNavBuiltinAllowed(k) {
return nil, ErrInvalidSiteSetting
}
if _, dup := seenBuiltin[k]; dup {
continue
}
seenBuiltin[k] = struct{}{}
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
case HeaderNavItemCustom:
label := strings.TrimSpace(item.Label)
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
return nil, ErrInvalidSiteSetting
}
u, ok := NormalizeFooterURL(item.URL)
if !ok {
return nil, ErrInvalidSiteSetting
}
out = append(out, HeaderNavItem{
Type: HeaderNavItemCustom,
Label: label,
URL: u,
NewTab: item.NewTab,
})
default:
return nil, ErrInvalidSiteSetting
}
}
return out, nil
}
// parseHeaderNavJSON 读取时宽容:坏 JSON 回落默认;单条非法跳过;内置重复跳过;
// 显式空数组解析为空切片(全部隐藏),由调用方与「缺行」区分
func parseHeaderNavJSON(raw string) []HeaderNavItem {
var in []headerNavItemIn
if err := json.Unmarshal([]byte(raw), &in); err != nil {
return DefaultHeaderNav()
}
out := make([]HeaderNavItem, 0, len(in))
seenBuiltin := make(map[string]struct{}, len(in))
for _, item := range in {
switch strings.TrimSpace(item.Type) {
case HeaderNavItemBuiltin:
k := strings.TrimSpace(strings.ToLower(item.Key))
if !headerNavBuiltinAllowed(k) {
continue
}
if _, dup := seenBuiltin[k]; dup {
continue
}
seenBuiltin[k] = struct{}{}
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
case HeaderNavItemCustom:
label := strings.TrimSpace(item.Label)
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
continue
}
u, ok := NormalizeFooterURL(item.URL)
if !ok {
continue
}
newTab := false
if item.NewTab != nil {
newTab = *item.NewTab
} else if isExternalFooterURL(u) {
newTab = true
}
out = append(out, HeaderNavItem{Type: HeaderNavItemCustom, Label: label, URL: u, NewTab: newTab})
}
if len(out) >= MaxHeaderNavItems {
break
}
}
return out
}
func headerNavEqual(a, b []HeaderNavItem) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// HeaderNav 读取顶部导航配置;缺行/非法回落默认三项,显式 [] 返回空切片
func (s *SettingService) HeaderNav() []HeaderNavItem {
v, found, err := s.getValue(SettingKeyHeaderNav)
if err != nil || !found || strings.TrimSpace(v) == "" {
return DefaultHeaderNav()
}
return parseHeaderNavJSON(v)
}
// SetHeaderNav 校验并保存顶部导航配置;与默认三项完全一致时删键保持「缺行=默认」语义,
// 空数组原样写入(表示全部隐藏)
func (s *SettingService) SetHeaderNav(items []HeaderNavItem) error {
out, err := NormalizeHeaderNav(items)
if err != nil {
return err
}
if headerNavEqual(out, DefaultHeaderNav()) {
return s.deleteKey(SettingKeyHeaderNav)
}
b, err := json.Marshal(out)
if err != nil {
return err
}
return s.putValue(SettingKeyHeaderNav, string(b))
}

View File

@@ -0,0 +1,165 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newHeaderNavTestService(t *testing.T) *SettingService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.SiteSetting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewSettingService(db)
}
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
s := newHeaderNavTestService(t)
got := s.HeaderNav()
if len(got) != 4 ||
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) ||
got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) ||
got[3] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "snippets"}) {
t.Fatalf("missing row should fall back to builtin defaults, got %+v", got)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if len(pub.HeaderNav) != 4 {
t.Fatalf("Public().HeaderNav len = %d, want 4", len(pub.HeaderNav))
}
}
func TestSetHeaderNavRoundTrip(t *testing.T) {
s := newHeaderNavTestService(t)
want := []HeaderNavItem{
{Type: HeaderNavItemBuiltin, Key: "library"},
{Type: HeaderNavItemCustom, Label: "关于", URL: "/p/about", NewTab: false},
{Type: HeaderNavItemBuiltin, Key: "home"},
{Type: HeaderNavItemCustom, Label: "官网", URL: "https://example.com", NewTab: true},
}
if err := s.SetHeaderNav(want); err != nil {
t.Fatal(err)
}
got := s.HeaderNav()
if len(got) != len(want) {
t.Fatalf("len = %d, want %d (%+v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("item %d = %+v, want %+v", i, got[i], want[i])
}
}
}
func TestSetHeaderNavEmptyHidesAll(t *testing.T) {
s := newHeaderNavTestService(t)
if err := s.SetHeaderNav(nil); err != nil {
t.Fatal(err)
}
if got := s.HeaderNav(); len(got) != 0 {
t.Fatalf("explicit empty list should hide all menus, got %+v", got)
}
var rows int64
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
if rows != 1 {
t.Fatalf("empty list should be persisted, rows=%d", rows)
}
}
func TestSetHeaderNavDefaultDeletesKey(t *testing.T) {
s := newHeaderNavTestService(t)
if err := s.SetHeaderNav([]HeaderNavItem{{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}}); err != nil {
t.Fatal(err)
}
if err := s.SetHeaderNav(DefaultHeaderNav()); err != nil {
t.Fatal(err)
}
var rows int64
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
if rows != 0 {
t.Fatalf("default config should delete the row, rows=%d", rows)
}
}
func TestNormalizeHeaderNavRejectsInvalid(t *testing.T) {
longLabel := strings.Repeat("字", MaxHeaderNavLabelRunes+1)
cases := [][]HeaderNavItem{
{{Type: HeaderNavItemBuiltin, Key: "unknown"}},
{{Type: "weird", Key: "home"}},
{{Type: HeaderNavItemCustom, Label: "", URL: "/x"}},
{{Type: HeaderNavItemCustom, Label: longLabel, URL: "/x"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "javascript:alert(1)"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "relative"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "//evil.com/x"}},
}
for i, items := range cases {
if _, err := NormalizeHeaderNav(items); !errors.Is(err, ErrInvalidSiteSetting) {
t.Fatalf("case %d should fail, got %v", i, err)
}
}
tooMany := make([]HeaderNavItem, MaxHeaderNavItems+1)
for i := range tooMany {
tooMany[i] = HeaderNavItem{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}
}
if _, err := NormalizeHeaderNav(tooMany); !errors.Is(err, ErrInvalidSiteSetting) {
t.Fatalf("over-cap list should fail, got %v", err)
}
}
func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) {
got, err := NormalizeHeaderNav([]HeaderNavItem{
{Type: HeaderNavItemBuiltin, Key: "home"},
{Type: HeaderNavItemBuiltin, Key: " HOME "},
{Type: HeaderNavItemBuiltin, Key: "library"},
})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Key != "home" || got[1].Key != "library" {
t.Fatalf("builtin duplicates should be deduped, got %+v", got)
}
}
func TestParseHeaderNavJSONTolerant(t *testing.T) {
// 坏 JSON 回落默认
if got := parseHeaderNavJSON("{bad"); len(got) != 4 {
t.Fatalf("bad json should fall back to defaults, got %+v", got)
}
// 空数组 = 全部隐藏
if got := parseHeaderNavJSON("[]"); len(got) != 0 {
t.Fatalf("empty array should be respected, got %+v", got)
}
// 单条非法跳过;内置重复跳过;外链 new_tab 缺省为 true
got := parseHeaderNavJSON(`[
{"type":"builtin","key":"home"},
{"type":"builtin","key":"nope"},
{"type":"builtin","key":"home"},
{"type":"custom","label":"","url":"/x"},
{"type":"custom","label":"外链","url":"https://example.com"},
{"type":"custom","label":"站内","url":"/p/about","new_tab":true}
]`)
if len(got) != 3 {
t.Fatalf("want 3 valid items, got %+v", got)
}
if got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) {
t.Fatalf("item0 = %+v", got[0])
}
if got[1].Type != HeaderNavItemCustom || got[1].Label != "外链" || !got[1].NewTab {
t.Fatalf("external custom item should default new_tab=true, got %+v", got[1])
}
if got[2].Label != "站内" || !got[2].NewTab {
t.Fatalf("explicit new_tab=true should be kept, got %+v", got[2])
}
}

View File

@@ -16,8 +16,8 @@ import (
)
const (
hidePwdCookiePrefix = "j13_hp_"
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
hidePwdCookiePrefix = "j13_hp_"
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
hidePwdCookieVersion = "1"
)

View File

@@ -0,0 +1,276 @@
package service
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/draw"
xwebp "golang.org/x/image/webp"
)
// 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽>
// 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 <img srcset> 消费。
// 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。
// GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。
// 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。
// VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致)
const VariantWebPQuality = 78
// variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进)
var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920}
// IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸)
func IsVariantWidth(w int) bool {
for _, v := range variantWidths {
if v == w {
return true
}
}
return false
}
// variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁
var variantGenLocks sync.Map
// extMIME 扩展名 → MIME(含 SVG/ICO 透传类型)
func extMIME(ext string) string {
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".ico":
return "image/x-icon"
}
return ""
}
// variantCacheFile 变体缓存路径:.thumbs/v/<sha1(url)>.w<w>.webp
func (s *UploadService) variantCacheFile(url string, w int) string {
sum := sha1.Sum([]byte(url))
name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp"
return filepath.Join(s.dir, ".thumbs", "v", name)
}
// variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在
func variantCacheFresh(cache string, modTime time.Time) bool {
ti, err := os.Stat(cache)
if err != nil || ti.IsDir() {
return false
}
return modTime.IsZero() || !ti.ModTime().Before(modTime)
}
// Variant 返回源图指定宽度的变体字节与 MIME。
func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) {
rawURL = strings.TrimSpace(rawURL)
if len(rawURL) == 0 || len(rawURL) > 512 {
return nil, "", errors.New("无效地址")
}
if !IsVariantWidth(width) {
return nil, "", errors.New("不支持的尺寸")
}
var (
srcData []byte // 远程源预读字节(本地源在锁内读取)
srcMIME string
srcPath string // 本地源绝对路径
modTime time.Time
remote bool
cache = s.variantCacheFile(rawURL, width)
)
switch {
case strings.HasPrefix(rawURL, "/uploads/"):
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/")))
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
return nil, "", errors.New("无效地址")
}
dir := filepath.Dir(rel)
catOK := false
for _, cat := range mediaLibraryCategories {
if cat.Dir == dir {
catOK = true
break
}
}
if !catOK {
return nil, "", errors.New("无效地址")
}
ext := strings.ToLower(filepath.Ext(rel))
mime := extMIME(ext)
if mime == "" {
return nil, "", errors.New("非图片")
}
srcPath = filepath.Join(s.dir, rel)
info, err := os.Stat(srcPath)
if err != nil || info.IsDir() {
return nil, "", errors.New("文件不存在")
}
// 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存)
if ext == ".gif" || ext == ".svg" || ext == ".ico" {
data, err := os.ReadFile(srcPath)
return data, mime, err
}
srcMIME = mime
modTime = info.ModTime()
case strings.HasPrefix(rawURL, "/api/media/"):
if s.ops == nil {
return nil, "", errors.New("无效地址")
}
id := strings.TrimPrefix(rawURL, "/api/media/")
if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 {
return nil, "", errors.New("无效地址")
}
remote = true
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
r, mime, e := s.ops.OpenObject(ctx, id, true)
if e != nil {
return nil, "", errors.New("文件暂不可用")
}
defer r.Close()
switch mime {
case "image/jpeg", "image/png", "image/webp":
case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon":
data, err := io.ReadAll(r)
return data, mime, err
default:
return nil, "", errors.New("非图片")
}
srcData, e = io.ReadAll(r)
if e != nil {
return nil, "", e
}
srcMIME = mime
default:
return nil, "", errors.New("无效地址")
}
// 快速路径:缓存已新鲜直接返回(无锁)
if variantCacheFresh(cache, modTime) {
if data, err := os.ReadFile(cache); err == nil {
return data, "image/webp", nil
}
}
// 并发生成去重:同 url+w 只生成一次
sum := sha1.Sum([]byte(rawURL))
key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width)
mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{})
lk := mu.(*sync.Mutex)
lk.Lock()
defer lk.Unlock()
// double-check:等锁期间可能已由他人生成
if variantCacheFresh(cache, modTime) {
if data, err := os.ReadFile(cache); err == nil {
return data, "image/webp", nil
}
}
if !remote {
data, err := os.ReadFile(srcPath)
if err != nil {
return nil, "", err
}
srcData = data
}
out, m, err := variantFromBytes(srcData, srcMIME, width)
if err != nil {
return nil, "", err // 如动图 WebP,调用方回退原图
}
// 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应
if m == "image/webp" {
_ = atomicWriteFile(cache, out)
}
return out, m, nil
}
// variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图);
// 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。
func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) {
img, err := decodeImageBytes(srcData, srcMIME == "image/webp")
if err != nil {
return nil, "", err
}
b := img.Bounds()
if b.Dx() <= width {
return srcData, srcMIME, nil
}
nh := max(1, b.Dy()*width/b.Dx())
dst := scaleImage(img, width, nh)
out, err := encodeLossyWebP(dst, VariantWebPQuality)
if err != nil {
return nil, "", err
}
return out, "image/webp", nil
}
// ---------- 与 MediaThumb 共用的图片处理核心 ----------
// decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器
func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) {
if isWebP {
return xwebp.Decode(bytes.NewReader(data))
}
img, _, err := image.Decode(bytes.NewReader(data))
return img, err
}
// scaleImage CatmullRom 高质量缩放到指定宽高
func scaleImage(img image.Image, nw, nh int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
return dst
}
// encodeLossyWebP 有损 WebP 编码
func encodeLossyWebP(img image.Image, quality int) ([]byte, error) {
var buf bytes.Buffer
if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// atomicWriteFile 临时文件 + rename 原子落盘
func atomicWriteFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".partial"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}

View File

@@ -0,0 +1,282 @@
package service
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/freefire/jiang13-bbs/config"
webpenc "github.com/gen2brain/webp"
xwebp "golang.org/x/image/webp"
)
// solidImg 生成渐变纯色测试图
func solidImg(w, h int) *image.RGBA {
src := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
return src
}
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
t.Helper()
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(abs)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
if err := encode(f); err != nil {
t.Fatalf("encode: %v", err)
}
}
func encodeJPEG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
}
}
func encodePNG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return png.Encode(w, img)
}
}
func encodeWebP(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
}
}
func encodeGIF(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return gif.Encode(w, img, nil)
}
}
// decodeVariant 解码返回的 WebP 变体
func decodeVariant(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := xwebp.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode variant webp: %v", err)
}
return img
}
func TestIsVariantWidth(t *testing.T) {
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
t.Fatal("whitelist endpoints should be valid")
}
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
t.Fatal("non-whitelist width should be invalid")
}
}
func TestVariantRejectsInvalidInput(t *testing.T) {
s, _ := newUploadTestService(t)
cases := []struct {
name string
url string
w int
}{
{"非法尺寸", "/uploads/images/a.webp", 100},
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
{"非白名单目录", "/uploads/secret/a.webp", 96},
{"非图片扩展名", "/uploads/images/a.txt", 96},
{"外部地址", "https://evil.com/a.webp", 96},
{"空地址", "", 96},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
t.Fatal("want error, got nil")
}
})
}
}
func TestVariantMissingFile(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
t.Fatal("missing source should error")
}
}
func TestVariantPassthroughSmallImage(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/small.webp"
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
data, m, err := s.Variant(url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp mime, got %s", m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
if !bytes.Equal(data, src) {
t.Fatal("small image should be returned byte-for-byte")
}
}
func TestVariantResizesByWidthLandscape(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/wide.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
data, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 384 || b.Dy() != 192 {
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantResizesByWidthPortrait(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/tall.png"
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
data, _, err := s.Variant(url, 256)
if err != nil {
t.Fatalf("variant: %v", err)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 256 || b.Dy() != 512 {
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
s, dir := newUploadTestService(t)
cases := []struct {
name string
url string
mime string
body []byte
}{
{"gif", "/uploads/images/a.gif", "image/gif", nil},
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.body != nil {
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
_, err := w.Write(tc.body)
return err
})
} else {
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
}
data, m, err := s.Variant(tc.url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != tc.mime {
t.Fatalf("want %s, got %s", tc.mime, m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
if !bytes.Equal(data, src) {
t.Fatalf("%s should pass through unchanged", tc.name)
}
})
}
}
func TestVariantCachedAndReused(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/cache.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
first, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("first variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
cache := s.variantCacheFile(url, 384)
if info, err := os.Stat(cache); err != nil || info.IsDir() {
t.Fatalf("cache file should exist: %v", err)
}
second, _, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("second variant: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatal("cached variant should be byte-identical")
}
}
func TestVariantCacheFreshness(t *testing.T) {
_, dir := newUploadTestService(t)
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
// 远程对象:modTime 零值,存在即新鲜
if !variantCacheFresh(cache, time.Time{}) {
t.Fatal("remote cache should be fresh when file exists")
}
// 本地源:mtime 新于缓存 → 不新鲜
future := time.Now().Add(1 * time.Hour)
if variantCacheFresh(cache, future) {
t.Fatal("cache should be stale vs newer source mtime")
}
}
func TestVariantRemoteRejectsBadID(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
t.Fatal("id with slash should be rejected")
}
}
func TestVariantRemoteUnavailable(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
t.Fatal("missing remote object should error")
}
}
func TestVariantRemoteWithoutOps(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
t.Fatal("remote url without ops wired should error")
}
}

View File

@@ -14,7 +14,7 @@ const (
PeriodMonth = "month"
PeriodAll = "all"
MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分
MetricAll = "all" // 综合:总榜=累计积分,周/月=周期内获得积分
MetricPost = "post" // 发帖最多
MetricComment = "comment" // 回复最多
MetricLike = "like" // 获赞最多
@@ -112,6 +112,41 @@ func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRo
return out, nil
}
// earnedPoints 周期内累计获得积分:point_ledgers 正入账合计(排除 bounty_refund,
// 与 users.total_points 启动回填口径一致),仅统计未封禁用户。
// 签到、发帖/回复奖励、被推荐、悬赏采纳等均按实际入账分值计入贡献值
func (s *LeaderboardService) earnedPoints(from, to *time.Time) (map[uint]int64, error) {
clause := ""
var timeArgs []interface{}
if from != nil {
clause += " AND l.created_at >= ?"
timeArgs = append(timeArgs, *from)
}
if to != nil {
clause += " AND l.created_at < ?"
timeArgs = append(timeArgs, *to)
}
sql := "SELECT l.user_id AS user_id, SUM(l.delta) AS pts FROM point_ledgers l" +
" JOIN users AS u ON u.id = l.user_id" +
" WHERE l.delta > 0 AND l.reason <> ? AND u.banned = ? AND u.deleted_at IS NULL" + clause +
" GROUP BY l.user_id"
args := []interface{}{model.PointReasonBountyRefund, false}
args = append(args, timeArgs...)
var rows []struct {
UserID uint
Pts int64
}
if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[uint]int64, len(rows))
for _, r := range rows {
out[r.UserID] = r.Pts
}
return out, nil
}
// answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数,
// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计)
func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) {
@@ -167,7 +202,7 @@ func (s *LeaderboardService) statsFor(from, to *time.Time) (map[uint]statsRow, e
return stats, nil
}
// scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween)
// scoreOf 细分指标 → 排名得分;「全部」维度走积分口径(见 boardBetween / earnedPoints)
func scoreOf(metric string, st statsRow) int64 {
switch metric {
case MetricPost:
@@ -178,9 +213,8 @@ func scoreOf(metric string, st statsRow) int64 {
return st.LikeCount
case MetricAnswer:
return st.AnswerCount
default:
return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount
}
return 0
}
// entryFromUser 由用户行构造榜单条目(不含统计)
@@ -214,7 +248,8 @@ func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *tim
}
// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。
// 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。
// 「全部」维度走积分口径(总榜=total_points,周/月=周期内获得积分,见 earnedPoints),
// 细分维度走统计聚合口径。
func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) {
if metric == MetricAll && from == nil {
var users []model.User
@@ -245,9 +280,29 @@ func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric st
if err != nil {
return nil, err
}
entries := make([]LeaderboardEntry, 0, len(stats))
// 周/月·全部:贡献值=周期内累计获得积分,候选集并入仅签到/获奖无内容的用户
usePts := metric == MetricAll && from != nil
pts := map[uint]int64{}
if usePts {
if pts, err = s.earnedPoints(from, to); err != nil {
return nil, err
}
}
cand := make(map[uint]statsRow, len(stats)+len(pts))
for id, r := range stats {
cand[id] = r
}
for id := range pts {
if _, ok := cand[id]; !ok {
cand[id] = statsRow{}
}
}
entries := make([]LeaderboardEntry, 0, len(cand))
for id, r := range cand {
score := scoreOf(metric, r)
if usePts {
score = pts[id]
}
if score <= 0 {
continue
}

View File

@@ -0,0 +1,67 @@
package service
import (
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// 贡献值口径:earnedPoints 只累计窗口内正入账积分(排除 bounty_refund),
// 支出、悬赏退回、封禁用户、窗口外入账均不计
func TestEarnedPointsWindowAndExclusions(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.PointLedger{}); err != nil {
t.Fatalf("migrate: %v", err)
}
now := time.Now()
weekAgo := now.AddDate(0, 0, -7)
users := []*model.User{
{Username: "earner", Password: "x"},
{Username: "refund_only", Password: "x"},
{Username: "banned", Password: "x", Banned: true},
{Username: "old_earner", Password: "x"},
}
for _, u := range users {
if err := db.Create(u).Error; err != nil {
t.Fatalf("seed user %s: %v", u.Username, err)
}
}
a, d := users[0].ID, users[3].ID
ledgers := []model.PointLedger{
{UserID: a, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now},
{UserID: a, Delta: 3, Reason: model.PointReasonReplyReward, Balance: 8, CreatedAt: now},
{UserID: a, Delta: 2, Reason: model.PointReasonStreakBonus, Balance: 10, CreatedAt: now.Add(-time.Hour)},
{UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计
{UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计
{UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计
{UserID: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外
}
if err := db.Create(&ledgers).Error; err != nil {
t.Fatalf("seed ledgers: %v", err)
}
s := NewLeaderboardService(db)
from := weekAgo
pts, err := s.earnedPoints(&from, nil)
if err != nil {
t.Fatalf("earnedPoints: %v", err)
}
if len(pts) != 1 || pts[a] != 10 {
t.Fatalf("周窗内应仅 earner 计 10 分,got %v", pts)
}
all, err := s.earnedPoints(nil, nil)
if err != nil {
t.Fatalf("earnedPoints(all): %v", err)
}
if len(all) != 2 || all[a] != 10 || all[d] != 5 {
t.Fatalf("全窗口:earner=10、old_earner=5,退回/封禁不计,got %v", all)
}
}

View File

@@ -8,6 +8,8 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -20,53 +22,110 @@ import (
"github.com/freefire/jiang13-bbs/model"
)
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包,
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包 / 帖子图片包,
// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。
// 支持预检(dry-run,不写库,返回用户 / 帖子 / 评论清单供勾选);ImportRecord 去重保证幂等可重复导入。
// 支持选择性导入:按旧用户勾选是否建号、内容归属(指定已有账号 / 站长)、按帖子 / 评论排除。
// 帖子图片:正文引用的旧站 /uploads/posts/<名> 从图片包落盘到新站 uploads/images 并改写 URL。
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
const (
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
LegacyDBMaxBytes = 64 << 20
// LegacyZipMaxBytes 头像压缩包上传上限
// LegacyZipMaxBytes 头像 / 帖子图片压缩包上传上限
LegacyZipMaxBytes = 128 << 20
// legacyAvatarFileMax 单个头像解压上限
legacyAvatarFileMax = 8 << 20
// legacyImageFileMax 单张帖子图片解压上限
legacyImageFileMax = 20 << 20
// legacySignatureMaxRunes 新站 Signature 列上限
legacySignatureMaxRunes = 255
// legacyEmailMaxRunes 新站 Email 列上限
legacyEmailMaxRunes = 128
// legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致)
legacyAvatarPrefix = "/uploads/avatars/"
// legacyPostImagePrefix 旧站帖子图片 URL 约定前缀
legacyPostImagePrefix = "/uploads/posts/"
// legacyImagePrefix 新站帖子图片落盘后的 URL 前缀
legacyImagePrefix = "/uploads/images/"
// legacyPreviewLimit 预检清单条数上限,防止超大库拖垮响应
legacyPreviewLimit = 2000
// legacyDetailLimit 报告明细条数上限,防止超长响应
legacyDetailLimit = 50
)
var (
ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库")
ErrLegacyBadZip = errors.New("头像压缩包无法读取")
ErrLegacyBadZip = errors.New("压缩包无法读取")
ErrLegacyBadTarget = errors.New("内容归属目标账号不存在")
)
// legacyPostImageRe 匹配正文中对旧站帖子图片的引用(相对路径或任意域名绝对 URL),
// 捕获组为纯文件名(字符集不含路径分隔符,杜绝目录穿越)。
var legacyPostImageRe = regexp.MustCompile(`(?:https?://[^()\[\]\s]+)?/uploads/posts/([A-Za-z0-9._\-]+)`)
// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单)
func LegacyImportSource(source string) bool {
return source == "jiang13"
}
// LegacyImportOptions 导入选项
// LegacyImportOptions 导入选项(skip / 归属参数由 handler 从表单解析)
type LegacyImportOptions struct {
WithContent bool // 同时导入板块 / 帖子 / 评论
DryRun bool // 预检:只统计不写库
WithContent bool // 同时导入板块 / 帖子 / 评论
DryRun bool // 预检:只统计不写库,并返回勾选清单
SkipUserIDs map[uint]bool // 不创建账号的旧用户(内容仍按归属规则落位)
OperatorUsers map[uint]bool // 内容明确归到站长的旧用户
UserTargetNames map[uint]string // 旧用户ID → 指定已有账号用户名(内容归属)
UserTargetMap map[uint]uint // (内部)UserTargetNames 解析后的目标账号 ID
SkipPostIDs map[uint]bool // 排除的旧帖(其评论自动跳过)
SkipCommentIDs map[uint]bool // 排除的旧评论
}
// LegacyImportReport 导入结果摘要(JSON 返回给前端)
type LegacyImportReport struct {
DryRun bool `json:"dry_run"`
Users LegacyUserReport `json:"users"`
Boards *LegacyBoardReport `json:"boards,omitempty"`
Posts *LegacyPostReport `json:"posts,omitempty"`
Comments *LegacyCommentReport `json:"comments,omitempty"`
Notes []string `json:"notes,omitempty"`
DryRun bool `json:"dry_run"`
Users LegacyUserReport `json:"users"`
Boards *LegacyBoardReport `json:"boards,omitempty"`
Posts *LegacyPostReport `json:"posts,omitempty"`
Comments *LegacyCommentReport `json:"comments,omitempty"`
UserList []LegacyUserPreview `json:"user_list,omitempty"` // 仅 dry-run:勾选清单
PostList []LegacyPostPreview `json:"post_list,omitempty"` // 仅 dry-run
CommentList []LegacyCommentPreview `json:"comment_list,omitempty"` // 仅 dry-run
Notes []string `json:"notes,omitempty"`
}
// LegacyUserPreview 预检清单:旧用户(供勾选是否建号 / 内容归属)
type LegacyUserPreview struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Posts int `json:"posts"`
Comments int `json:"comments"`
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
HasAvatar bool `json:"has_avatar"`
}
// LegacyPostPreview 预检清单:旧帖(取消勾选即排除)
type LegacyPostPreview struct {
ID uint `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Board string `json:"board"`
CreatedAt time.Time `json:"created_at"`
Poll bool `json:"poll"` // 投票帖不可迁移
Published bool `json:"published"` // 非公开帖导入时自动跳过
}
// LegacyCommentPreview 预检清单:旧评论(取消勾选即排除)
type LegacyCommentPreview struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Author string `json:"author"`
Excerpt string `json:"excerpt"`
CreatedAt time.Time `json:"created_at"`
Published bool `json:"published"`
}
// LegacyUserReport 用户导入明细
@@ -74,38 +133,47 @@ type LegacyUserReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Excluded int `json:"excluded"` // 手动排除建号
AvatarWritten int `json:"avatar_written"`
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失
Failed []string `json:"failed,omitempty"`
}
// LegacyBoardReport 板块导入明细
type LegacyBoardReport struct {
Created int `json:"created"` // 新建(预检时为「将新建」)
Reused int `json:"reused"` // 复用同名已有板块
Created int `json:"created"` // 新建(预检时为「将新建」)
Reused int `json:"reused"` // 复用同名已有板块
Names []string `json:"names,omitempty"` // 新建板块名
}
// LegacyPostReport 帖子导入明细
type LegacyPostReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"` // 已导入过(去重记录)
PollsSkipped int `json:"polls_skipped"`
PollTitles []string `json:"poll_titles,omitempty"`
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
Failed []string `json:"failed,omitempty"`
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"` // 已导入过(去重记录)
Excluded int `json:"excluded"` // 手动排除
ExcludedDetail []string `json:"excluded_detail,omitempty"`
PollsSkipped int `json:"polls_skipped"`
PollTitles []string `json:"poll_titles,omitempty"`
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
ImagesWritten int `json:"images_written"` // 正文图片迁移落盘数
ImagesMissing []string `json:"images_missing,omitempty"` // 包内缺失 / 落盘失败,保留原链接
Failed []string `json:"failed,omitempty"`
}
// LegacyCommentReport 评论导入明细
type LegacyCommentReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
OwnerFallback []string `json:"owner_fallback,omitempty"`
Failed []string `json:"failed,omitempty"`
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Excluded int `json:"excluded"` // 手动排除(含所属帖子被排除)
ExcludedDetail []string `json:"excluded_detail,omitempty"`
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
OwnerFallback []string `json:"owner_fallback,omitempty"`
ImagesWritten int `json:"images_written"`
ImagesMissing []string `json:"images_missing,omitempty"`
Failed []string `json:"failed,omitempty"`
}
// ===== 旧库行结构(只映射所需列) =====
@@ -176,8 +244,16 @@ func (legacyCommentRow) TableName() string { return "comments" }
// LegacyImportService 管理后台「数据导入」
type LegacyImportService struct {
db *gorm.DB
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars,帖子图片写入 uploads/images
md *htmltomarkdown.Converter
ensureHall func(userID uint) error // 建号后加入默认大厅的钩子(可空;与注册链路一致)
}
// WithHallMembership 注入「加入默认全站大厅」回调(chatSvc.EnsureDefaultMembership)。
// 导入建号绕过注册链路,需显式接线保持行为一致;错误静默,幂等可补齐。
func (s *LegacyImportService) WithHallMembership(fn func(userID uint) error) *LegacyImportService {
s.ensureHall = fn
return s
}
func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService {
@@ -189,77 +265,127 @@ func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService
}
}
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。
// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
// zipPack 旧站文件包(头像 / 帖子图片)索引:纯文件名 → 条目。
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
type zipPack struct {
idx map[string]*zip.File
close func()
}
func openZipPack(zipPath string) (*zipPack, error) {
if zipPath == "" {
return &zipPack{idx: map[string]*zip.File{}, close: func() {}}, nil
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, ErrLegacyBadZip
}
p := &zipPack{idx: make(map[string]*zip.File, len(zr.File)), close: func() { _ = zr.Close() }}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
name := strings.ReplaceAll(f.Name, "\\", "/")
parts := strings.Split(name, "/")
var key string
switch {
case len(parts) == 1:
key = parts[0]
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
key = parts[1]
default:
continue
}
if key == "" || key == "." || key == ".." {
continue
}
if _, exists := p.idx[key]; !exists {
p.idx[key] = f
}
}
return p, nil
}
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath / imagesZipPath
// 为可选头像包与帖子图片包(空串表示未上传)。operatorID 为执行导入的账号(站长),
// 作者缺失的帖子/评论归属到该账号。
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath, imagesZipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
if err := validateSQLiteMagic(dbPath); err != nil {
return nil, err
}
// 归属目标:用户名 → 本站已有账号 ID(含软删账号,内容归属不变)
if len(opts.UserTargetNames) > 0 {
resolved := make(map[uint]uint, len(opts.UserTargetNames))
for oldID, name := range opts.UserTargetNames {
var u model.User
if err := s.db.Unscoped().Where("username = ?", name).First(&u).Error; err != nil {
return nil, fmt.Errorf("%w: %s", ErrLegacyBadTarget, name)
}
resolved[oldID] = u.ID
}
opts.UserTargetNames = nil
opts.UserTargetMap = resolved
}
oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(dbPath)+"?mode=ro"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrLegacyInvalidSQLite, err)
}
// 导入结束释放 SQLite 句柄,避免 Windows 上文件被占用
if sqlDB, err := oldDB.DB(); err == nil {
defer func() { _ = sqlDB.Close() }()
}
avatars, err := openZipPack(zipPath)
if err != nil {
return nil, err
}
defer avatars.close()
images, err := openZipPack(imagesZipPath)
if err != nil {
return nil, err
}
defer images.close()
if !opts.DryRun {
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "avatars"), 0o755)
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "images"), 0o755)
}
rep := &LegacyImportReport{DryRun: opts.DryRun}
rep.Users = s.importUsers(oldDB, zipPath, opts)
if opts.DryRun {
s.buildPreview(oldDB, rep)
}
rep.Users = s.importUsers(oldDB, avatars, opts)
if !opts.WithContent {
return rep, nil
}
boardMap, boardRep := s.importBoards(oldDB, opts)
rep.Boards = boardRep
rep.Posts = s.importPosts(oldDB, boardMap, operatorID, opts, rep)
rep.Comments = s.importComments(oldDB, operatorID, opts, rep)
rep.Posts = s.importPosts(oldDB, boardMap, images, operatorID, opts, rep)
rep.Comments = s.importComments(oldDB, images, operatorID, opts, rep)
return rep, nil
}
// ===== 用户 =====
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts LegacyImportOptions) LegacyUserReport {
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, avatars *zipPack, opts LegacyImportOptions) LegacyUserReport {
var rows []legacyUserRow
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}}
}
// 头像包索引:文件名 → 条目。
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
avatars := map[string]*zip.File{}
if zipPath != "" {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}}
}
defer zr.Close()
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
name := strings.ReplaceAll(f.Name, "\\", "/")
parts := strings.Split(name, "/")
var key string
switch {
case len(parts) == 1:
key = parts[0]
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
key = parts[1]
default:
continue
}
if key == "" || key == "." || key == ".." {
continue
}
if _, exists := avatars[key]; !exists {
avatars[key] = f
}
}
}
rep := LegacyUserReport{Total: len(rows)}
avatarDir := filepath.Join(s.uploadsDir, "avatars")
for _, u := range rows {
// 手动排除:不创建账号,其内容按归属规则落位(见 resolveAuthor)
if opts.SkipUserIDs[u.ID] {
rep.Excluded++
continue
}
username := strings.TrimSpace(u.Username)
if username == "" || u.Password == "" {
rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID))
@@ -277,7 +403,7 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
continue
}
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun)
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars.idx, avatarDir, opts.DryRun)
if err != nil {
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err))
continue
@@ -308,6 +434,9 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err))
continue
}
if s.ensureHall != nil {
_ = s.ensureHall(user.ID) // 加入默认全站大厅(幂等,失败不阻断导入)
}
rep.Imported++
}
return rep
@@ -342,42 +471,111 @@ func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*z
return "", false, nil // 包内缺失
}
dst := filepath.Join(avatarDir, name)
if _, err := os.Stat(dst); err == nil {
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
}
if dryRun {
if _, err := os.Stat(dst); err == nil {
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
}
return legacyAvatarPrefix + name, true, nil
}
wrote, err := extractZipEntry(f, dst, legacyAvatarFileMax)
if err != nil {
return "", false, err
}
return legacyAvatarPrefix + name, wrote, nil
}
// extractZipEntry 把 zip 条目解到 dst(先写临时文件再 rename,避免中断留下半截文件)。
// 目标已存在时不写入直接返回 false(幂等;兼容 Windows 上目标已存在时 Rename 失败的语义)。
func extractZipEntry(f *zip.File, dst string, max int64) (bool, error) {
if _, err := os.Stat(dst); err == nil {
return false, nil
}
src, err := f.Open()
if err != nil {
return "", false, err
return false, err
}
defer src.Close()
// 先写临时文件再 rename,避免中断留下半截文件
out, err := os.CreateTemp(avatarDir, ".legacy-*")
out, err := os.CreateTemp(filepath.Dir(dst), ".legacy-*")
if err != nil {
return "", false, err
return false, err
}
tmpName := out.Name()
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1))
written, err := io.Copy(out, io.LimitReader(src, max+1))
if closeErr := out.Close(); err == nil {
err = closeErr
}
if err != nil {
return "", false, err
return false, err
}
if written > legacyAvatarFileMax {
return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20)
if written > max {
return false, fmt.Errorf("文件超过 %dMB 上限", max>>20)
}
if err := os.Rename(tmpName, dst); err != nil {
// Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
if _, statErr := os.Stat(dst); statErr == nil {
return legacyAvatarPrefix + name, false, nil
return false, nil // Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
}
return "", false, err
return false, err
}
return legacyAvatarPrefix + name, true, nil
return true, nil
}
// ===== 帖子 / 评论图片迁移 =====
// legacyImageStat 单次导入的图片迁移统计(跨帖子/评论累计)
type legacyImageStat struct {
written int
missing map[string]bool
}
func newImageStat() *legacyImageStat {
return &legacyImageStat{missing: map[string]bool{}}
}
// names 返回缺失文件名(排序去重)
func (st *legacyImageStat) names() []string {
out := make([]string, 0, len(st.missing))
for k := range st.missing {
out = append(out, k)
}
sort.Strings(out)
return out
}
// migrateContentImages 把内容里引用的旧站帖子图片(/uploads/posts/<名>,含旧域名绝对 URL)
// 从图片包落盘到新站 uploads/images(文件名原样保留),并把 URL 改写为新站相对路径。
// 包内缺失或落盘失败的引用保留原链接并计入 missing。dryRun 只统计不落盘。
func (s *LegacyImportService) migrateContentImages(content string, pack *zipPack, dryRun bool, stat *legacyImageStat) string {
if !strings.Contains(content, legacyPostImagePrefix) {
return content
}
return legacyPostImageRe.ReplaceAllStringFunc(content, func(m string) string {
name := legacyPostImageRe.FindStringSubmatch(m)[1]
if name == "." || name == ".." || name != path.Base(name) {
return m
}
f, ok := pack.idx[name]
if !ok {
stat.missing[name] = true
return m
}
dst := filepath.Join(s.uploadsDir, "images", name)
if dryRun {
if _, err := os.Stat(dst); err != nil {
stat.written++
}
return legacyImagePrefix + name
}
wrote, err := extractZipEntry(f, dst, legacyImageFileMax)
if err != nil {
stat.missing[name] = true
return m
}
if wrote {
stat.written++
}
return legacyImagePrefix + name
})
}
// ===== 板块 =====
@@ -461,7 +659,22 @@ func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint {
return m
}
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
// resolveAuthor 内容归属:管理员显式指定 > 旧用户名匹配 > 站长兜底。
// 第二个返回值表示是否因找不到作者而兜底到站长(显式指定不算,用于报告明细)。
func resolveAuthor(oldUserID uint, opts LegacyImportOptions, userMap map[uint]uint, operatorID uint) (uint, bool) {
if id, ok := opts.UserTargetMap[oldUserID]; ok && id > 0 {
return id, false
}
if opts.OperatorUsers[oldUserID] {
return operatorID, false
}
if id, ok := userMap[oldUserID]; ok {
return id, false
}
return operatorID, true
}
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
out := &LegacyPostReport{}
if boardMap == nil || len(boardMap) == 0 {
out.Failed = append(out.Failed, "无可用板块映射,帖子未导入")
@@ -477,6 +690,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
userMap := s.mapLegacyUsers(oldDB)
missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback
convFailed := 0
imgStat := newImageStat()
for _, p := range rows {
title := strings.TrimSpace(p.Title)
@@ -484,6 +698,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID))
continue
}
if opts.SkipPostIDs[p.ID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("%s (手动排除)", title))
continue
}
if p.PostType == "poll" {
out.PollsSkipped++
out.PollTitles = append(out.PollTitles, title)
@@ -509,13 +728,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
}
}
// 作者映射,缺失归站长
authorID := operatorID
if id, ok := userMap[p.UserID]; ok {
authorID = id
} else if !missingAuthors[p.UserID] {
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
authorID, fellBack := resolveAuthor(p.UserID, opts, userMap, operatorID)
if fellBack && !missingAuthors[p.UserID] {
missingAuthors[p.UserID] = true
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID))
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未匹配到账号,归到站长)", p.UserID))
}
// HTML → Markdown;失败保留原文(正文不丢)
@@ -527,25 +744,26 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
convFailed++
}
}
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
if opts.DryRun {
out.Imported++
continue
}
post := model.Post{
BoardID: boardID,
UserID: authorID,
Title: truncateRunesN(title, 256),
Content: content,
Tags: p.Tags,
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
Pinned: p.Pinned,
Recommended: p.Featured,
Status: model.ContentStatusPublished,
LikeCount: p.LikeCount,
ViewCount: p.ViewCount,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
BoardID: boardID,
UserID: authorID,
Title: truncateRunesN(title, 256),
Content: content,
Tags: p.Tags,
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
Pinned: p.Pinned,
Recommended: p.Featured,
Status: model.ContentStatusPublished,
LikeCount: p.LikeCount,
ViewCount: p.ViewCount,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
if err := s.db.Create(&post).Error; err != nil {
out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err))
@@ -557,13 +775,16 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
if convFailed > 0 {
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed))
}
out.ImagesWritten = imgStat.written
out.ImagesMissing = capList(imgStat.names())
out.OwnerFallback = capList(out.OwnerFallback)
out.ExcludedDetail = capList(out.ExcludedDetail)
out.Failed = capList(out.Failed)
out.PollTitles = capList(out.PollTitles)
return out
}
func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
func (s *LegacyImportService) importComments(oldDB *gorm.DB, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
out := &LegacyCommentReport{}
var rows []legacyCommentRow
if err := oldDB.Order("post_id ASC, id ASC").Find(&rows).Error; err != nil {
@@ -584,8 +805,20 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
userMap := s.mapLegacyUsers(oldDB)
missingAuthors := map[uint]bool{}
convFailed := 0
imgStat := newImageStat()
for _, cm := range rows {
// 所属帖子被排除 → 评论自动跳过(无落点)
if opts.SkipPostIDs[cm.PostID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (所属帖子被排除)", postTitles[cm.PostID], cm.ID))
continue
}
if opts.SkipCommentIDs[cm.ID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (手动排除)", postTitles[cm.PostID], cm.ID))
continue
}
if cm.Status != "published" {
out.Skipped++
out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status))
@@ -608,12 +841,11 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
}
}
authorID := operatorID
if id, ok := userMap[cm.UserID]; ok {
authorID = id
} else if !missingAuthors[cm.UserID] {
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
authorID, fellBack := resolveAuthor(cm.UserID, opts, userMap, operatorID)
if fellBack && !missingAuthors[cm.UserID] {
missingAuthors[cm.UserID] = true
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID))
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未匹配到账号,归到站长)", cm.UserID))
}
// HTML → Markdown;失败保留原文(内容不丢)
@@ -625,6 +857,7 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
convFailed++
}
}
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
if opts.DryRun {
out.Imported++
@@ -639,7 +872,10 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
if convFailed > 0 {
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed))
}
out.ImagesWritten = imgStat.written
out.ImagesMissing = capList(imgStat.names())
out.SkippedDetail = capList(out.SkippedDetail)
out.ExcludedDetail = capList(out.ExcludedDetail)
out.OwnerFallback = capList(out.OwnerFallback)
out.Failed = capList(out.Failed)
return out
@@ -699,6 +935,117 @@ func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uin
})
}
// ===== 预检清单 =====
// buildPreview 汇总旧用户 / 帖子 / 评论清单供管理员勾选导入范围,仅 dry-run 调用。
// 超出 legacyPreviewLimit 的清单截断并记入 Notes。
func (s *LegacyImportService) buildPreview(oldDB *gorm.DB, rep *LegacyImportReport) {
var users []legacyUserRow
if err := oldDB.Unscoped().Order("id ASC").Find(&users).Error; err != nil {
return
}
var boards []legacyBoardRow
// 与 importBoards/importPosts/importComments 保持一致:不含软删行(用户除外,软删用户内容仍需归属)
_ = oldDB.Order("id ASC").Find(&boards).Error
boardNames := map[uint]string{}
for _, b := range boards {
boardNames[b.ID] = b.Name
}
var posts []legacyPostRow
_ = oldDB.Order("id ASC").Find(&posts).Error
var comments []legacyCommentRow
_ = oldDB.Order("post_id ASC, id ASC").Find(&comments).Error
authorName := map[uint]string{}
postCount := map[uint]int{}
commentCount := map[uint]int{}
for _, u := range users {
name := strings.TrimSpace(u.Nickname)
if name == "" {
name = strings.TrimSpace(u.Username)
}
authorName[u.ID] = name
}
postTitles := map[uint]string{}
for _, p := range posts {
postCount[p.UserID]++
postTitles[p.ID] = p.Title
}
for _, cm := range comments {
commentCount[cm.UserID]++
}
existing := s.existingUsernames()
for _, u := range users {
rep.UserList = append(rep.UserList, LegacyUserPreview{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Posts: postCount[u.ID],
Comments: commentCount[u.ID],
Exists: existing[strings.ToLower(strings.TrimSpace(u.Username))],
HasAvatar: u.Avatar != "",
})
}
for _, p := range posts {
rep.PostList = append(rep.PostList, LegacyPostPreview{
ID: p.ID,
Title: p.Title,
Author: authorName[p.UserID],
Board: boardNames[p.BoardID],
CreatedAt: p.CreatedAt,
Poll: p.PostType == "poll",
Published: p.Status == "published",
})
}
for _, cm := range comments {
rep.CommentList = append(rep.CommentList, LegacyCommentPreview{
ID: cm.ID,
PostID: cm.PostID,
PostTitle: postTitles[cm.PostID],
Author: authorName[cm.UserID],
Excerpt: legacyExcerpt(cm.Content, 60),
CreatedAt: cm.CreatedAt,
Published: cm.Status == "published",
})
}
if len(rep.UserList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("用户清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.UserList = rep.UserList[:legacyPreviewLimit]
}
if len(rep.PostList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("帖子清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.PostList = rep.PostList[:legacyPreviewLimit]
}
if len(rep.CommentList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("评论清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.CommentList = rep.CommentList[:legacyPreviewLimit]
}
}
// existingUsernames 本站已有用户名集合(含软删,忽略大小写)
func (s *LegacyImportService) existingUsernames() map[string]bool {
var rows []model.User
if err := s.db.Unscoped().Select("username").Find(&rows).Error; err != nil {
return map[string]bool{}
}
set := make(map[string]bool, len(rows))
for _, u := range rows {
set[strings.ToLower(u.Username)] = true
}
return set
}
var legacyHTMLTagRe = regexp.MustCompile(`<[^>]*>`)
// legacyExcerpt 内容摘要:去 HTML 标签、压平空白后按 rune 截断(预检清单展示用)
func legacyExcerpt(s string, max int) string {
s = legacyHTMLTagRe.ReplaceAllString(s, " ")
s = strings.Join(strings.Fields(s), " ")
return truncateRunesN(s, max)
}
// ===== 工具 =====
// capList 明细条数截断,超出部分以「…等 N 条」收尾

View File

@@ -0,0 +1,281 @@
package service
import (
"archive/zip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// newLegacyTestEnv 建新站内存库 + 旧站 SQLite 文件 + 两个 zip 包,并灌入固定种子数据:
// 用户 alice(#1)/bob(#2)/carol(#3)/dave(#4),板块 闲聊(#10),
// 帖子 #100(alice)/#101(bob)/#102(carol 草稿)/#103(alice 投票)/#104(dave),
// 评论 #200(bob→100)/#201(carol→100)/#202(alice→101)。
func newLegacyTestEnv(t *testing.T) (*LegacyImportService, string, string, string, uint, map[uint]bool) {
t.Helper()
tmp := t.TempDir()
newDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open new db: %v", err)
}
if err := newDB.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.ImportRecord{}); err != nil {
t.Fatalf("migrate new db: %v", err)
}
admin := model.User{Username: "admin", Password: "x", Nickname: "站长", Role: model.RoleOwner}
if err := newDB.Create(&admin).Error; err != nil {
t.Fatalf("seed admin: %v", err)
}
oldPath := filepath.Join(tmp, "old.db")
oldDB, err := gorm.Open(sqlite.Open(oldPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open old db: %v", err)
}
if err := oldDB.AutoMigrate(&legacyUserRow{}, &legacyBoardRow{}, &legacyPostRow{}, &legacyCommentRow{}); err != nil {
t.Fatalf("migrate old db: %v", err)
}
seed := []interface{}{
&legacyUserRow{ID: 1, Username: "alice", Password: "h1", Nickname: "爱丽丝", Avatar: "/uploads/avatars/a1.jpg"},
&legacyUserRow{ID: 2, Username: "bob", Password: "h2", Nickname: "鲍勃"},
&legacyUserRow{ID: 3, Username: "carol", Password: "h3", Nickname: "卡罗尔"},
&legacyUserRow{ID: 4, Username: "dave", Password: "h4"},
&legacyBoardRow{ID: 10, Name: "闲聊"},
&legacyPostRow{ID: 100, BoardID: 10, UserID: 1, Title: "看图帖", Status: "published", PostType: "normal",
Content: `<p>看图</p><p><img src="/uploads/posts/img1.jpg"></p>`},
&legacyPostRow{ID: 101, BoardID: 10, UserID: 2, Title: "普通帖", Status: "published", PostType: "normal", Content: `<p>正文</p>`},
&legacyPostRow{ID: 102, BoardID: 10, UserID: 3, Title: "草稿帖", Status: "draft", PostType: "normal", Content: `<p>草稿</p>`},
&legacyPostRow{ID: 103, BoardID: 10, UserID: 1, Title: "投票帖", Status: "published", PostType: "poll", Content: `<p>投票</p>`},
&legacyPostRow{ID: 104, BoardID: 10, UserID: 4, Title: "缺图帖", Status: "published", PostType: "normal",
Content: `<p>缺图</p><p><img src="/uploads/posts/missing.jpg"></p>`},
&legacyCommentRow{ID: 200, PostID: 100, UserID: 2, Status: "published",
Content: `<p>补一张</p><p><img src="https://old.example.com/uploads/posts/img1.jpg"></p>`},
&legacyCommentRow{ID: 201, PostID: 100, UserID: 3, Status: "published", Content: `<p>顶</p>`},
&legacyCommentRow{ID: 202, PostID: 101, UserID: 1, Status: "published", Content: `<p>前排</p>`},
}
for _, row := range seed {
if err := oldDB.Create(row).Error; err != nil {
t.Fatalf("seed old db: %v", err)
}
}
// seed 完成即释放句柄,避免 TempDir 清理时文件被占用
if sqlDB, err := oldDB.DB(); err == nil {
_ = sqlDB.Close()
}
avatarZip := filepath.Join(tmp, "avatars.zip")
writeTestZip(t, avatarZip, map[string]string{"a1.jpg": "fake-avatar"})
imagesZip := filepath.Join(tmp, "images.zip")
writeTestZip(t, imagesZip, map[string]string{"img1.jpg": "fake-image"})
svc := NewLegacyImportService(newDB, filepath.Join(tmp, "uploads"))
// 入厅钩子桩:生产为 PostgreSQL,ChatService.EnsureDefaultMembership 里的
// GREATEST 表达式 SQLite 不支持,故用等价桩验证「建号即触发钩子」的接线
hallMembers := map[uint]bool{}
svc.WithHallMembership(func(id uint) error {
hallMembers[id] = true
return nil
})
return svc, oldPath, avatarZip, imagesZip, admin.ID, hallMembers
}
func writeTestZip(t *testing.T, path string, files map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create zip: %v", err)
}
w := zip.NewWriter(f)
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("zip entry: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("zip write: %v", err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("zip file close: %v", err)
}
}
func TestLegacyImportPreviewLists(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, _, _ := newLegacyTestEnv(t)
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, LegacyImportOptions{WithContent: true, DryRun: true}, 1)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if len(rep.UserList) != 4 || len(rep.PostList) != 5 || len(rep.CommentList) != 3 {
t.Fatalf("预检清单数量不符: users=%d posts=%d comments=%d", len(rep.UserList), len(rep.PostList), len(rep.CommentList))
}
if rep.UserList[0].Username != "alice" || !rep.UserList[0].HasAvatar {
t.Fatal("alice 预检项不符")
}
// 将导入:#100/#101/#104(草稿、投票排除)
if rep.Posts.Imported != 3 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("预检帖子统计不符: %+v", rep.Posts)
}
// dry-run 不落盘
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); !os.IsNotExist(err) {
t.Fatal("预检不应写文件")
}
}
func TestLegacyImportSelectiveRun(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, hallMembers := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
// 用户:alice / dave 建号,bob / carol 排除
if rep.Users.Imported != 2 || rep.Users.Excluded != 2 {
t.Fatalf("用户统计不符: %+v", rep.Users)
}
// 帖子:#100/#101/#104 导入,#102 草稿跳过,#103 投票
if rep.Posts.Imported != 3 || rep.Posts.Excluded != 0 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
// 评论:#200/#201 导入,#202 排除
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符: %+v", rep.Comments)
}
// 图片:#100 与 #200 各引用 img1.jpg,只落盘一次
if rep.Posts.ImagesWritten != 1 || len(rep.Comments.ImagesMissing) != 0 {
t.Fatalf("图片统计不符: posts=%+v comments=%+v", rep.Posts.ImagesWritten, rep.Comments.ImagesMissing)
}
if len(rep.Posts.ImagesMissing) != 1 || rep.Posts.ImagesMissing[0] != "missing.jpg" {
t.Fatalf("缺失图片应记录: %v", rep.Posts.ImagesMissing)
}
// 内容归属
var post100, post101 model.Post
if err := svc.db.Where("title = ?", "看图帖").First(&post100).Error; err != nil {
t.Fatalf("post100: %v", err)
}
if err := svc.db.Where("title = ?", "普通帖").First(&post101).Error; err != nil {
t.Fatalf("post101: %v", err)
}
var alice model.User
if err := svc.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("alice: %v", err)
}
if post100.UserID != alice.ID {
t.Fatalf("#100 应归 alice: got %d", post100.UserID)
}
if post101.UserID != adminID {
t.Fatalf("#101 应按 user_map 归 admin: got %d", post101.UserID)
}
// bob 的评论 #200 也归 admin;carol 的评论 #201 兜底归 admin
var cm200, cm201 model.Comment
if err := svc.db.Where("content LIKE ?", "%补一张%").First(&cm200).Error; err != nil {
t.Fatalf("cm200: %v", err)
}
if err := svc.db.Where("content LIKE ?", "%顶%").First(&cm201).Error; err != nil {
t.Fatalf("cm201: %v", err)
}
if cm200.UserID != adminID || cm201.UserID != adminID {
t.Fatalf("评论归属不符: cm200=%d cm201=%d", cm200.UserID, cm201.UserID)
}
// 图片 URL 改写 + 文件落盘
if !strings.Contains(post100.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(post100.Content, legacyPostImagePrefix) {
t.Fatalf("#100 图片 URL 未改写: %s", post100.Content)
}
if !strings.Contains(cm200.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(cm200.Content, "old.example.com") {
t.Fatalf("#200 图片 URL 未改写: %s", cm200.Content)
}
// 缺图帖:包内缺失的引用保留原链接,不改写
p104 := post104Content(t, svc)
if !strings.Contains(p104, legacyPostImagePrefix+"missing.jpg") || strings.Contains(p104, legacyImagePrefix) {
t.Fatalf("缺图引用应保留原链接: %s", p104)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); err != nil {
t.Fatalf("img1.jpg 未落盘: %v", err)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "avatars", "a1.jpg")); err != nil {
t.Fatalf("头像未落盘: %v", err)
}
// 导入建号的用户触发入厅钩子;被排除建号的 bob/carol 不触发
var dave model.User
if err := svc.db.Where("username = ?", "dave").First(&dave).Error; err != nil {
t.Fatalf("dave: %v", err)
}
if len(hallMembers) != 2 || !hallMembers[alice.ID] || !hallMembers[dave.ID] {
t.Fatalf("入厅钩子应只对 alice/dave 触发: %v", hallMembers)
}
// 幂等:重复导入全部跳过
rep2, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if rep2.Users.Imported != 0 || rep2.Posts.Imported != 0 || rep2.Comments.Imported != 0 {
t.Fatalf("重复导入应全部跳过: %+v", rep2)
}
if rep2.Users.Excluded != 2 || rep2.Posts.Skipped != 4 || rep2.Comments.Skipped != 2 {
t.Fatalf("重复导入统计不符: users=%+v posts=%+v comments=%+v", rep2.Users, rep2.Posts, rep2.Comments)
}
}
// TestLegacyImportSkipPostCascades 排除帖子时其评论自动跳过
func TestLegacyImportSkipPostCascades(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
if rep.Posts.Imported != 2 || rep.Posts.Excluded != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符(#202 应随 #101 排除): %+v", rep.Comments)
}
var n int64
svc.db.Model(&model.Post{}).Where("title = ?", "普通帖").Count(&n)
if n != 0 {
t.Fatal("#101 不应导入")
}
}
// TestLegacyImportBadTarget 归属目标账号不存在时报错
func TestLegacyImportBadTarget(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{WithContent: true, UserTargetNames: map[uint]string{2: "ghost"}}
_, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if !errors.Is(err, ErrLegacyBadTarget) {
t.Fatalf("应返回 ErrLegacyBadTarget: %v", err)
}
}
func post104Content(t *testing.T, svc *LegacyImportService) string {
t.Helper()
var p model.Post
if err := svc.db.Where("title = ?", "缺图帖").First(&p).Error; err != nil {
t.Fatalf("post104: %v", err)
}
return p.Content
}

652
backend/service/library.go Normal file
View File

@@ -0,0 +1,652 @@
package service
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 书库文件扩展名白名单(小写、不含点);附件仅提供下载分发,
// 书籍正文走章节阅读,不做在线预览
var libraryAllowedExts = map[string]struct{}{
"html": {}, "htm": {}, "pdf": {}, "epub": {}, "mobi": {}, "azw3": {},
"zip": {}, "7z": {}, "rar": {}, "txt": {}, "md": {},
"doc": {}, "docx": {}, "ppt": {}, "pptx": {},
}
const (
MaxLibraryFilesPerDoc = 20
libraryDescriptionMax = 20000
)
var (
ErrLibraryNotFound = errors.New("书库条目不存在")
ErrLibraryExtDenied = errors.New("不支持该文档格式")
ErrLibraryTooMany = errors.New("文件数量超过上限")
ErrLibraryNotDeleted = errors.New("仅已删除的条目可彻底删除")
)
// LibraryExtAllowed 上传扩展名是否在书库白名单内
func LibraryExtAllowed(ext string) bool {
_, ok := libraryAllowedExts[strings.ToLower(strings.TrimPrefix(ext, "."))]
return ok
}
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
var libraryDocWriteFields = []string{
"Slug", "Title", "Description", "CoverURL", "CoverWidth", "CoverHeight",
"Published", "SortOrder", "EntriesAuto", "Author", "CreatorID",
}
// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil)
type LibraryCreator struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
type LibraryDocDetail struct {
model.LibraryDoc
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
Files []model.LibraryFile `json:"files"`
Sections []model.LibrarySection `json:"sections"`
Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil)
}
// LibraryDocListItem 公开目录条目(description 纯文本截断,含文件概要)
type LibraryDocListItem struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
SortOrder int `json:"sort_order"`
FileCount int `json:"file_count"`
Files []model.LibraryFile `json:"files"`
Creator *LibraryCreator `json:"creator,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// LibraryInput 创建/更新书库条目入参
type LibraryInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"` // 书籍作者(自由文本,可空)
Description string `json:"description"`
CoverURL string `json:"cover_url"`
CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知)
CoverHeight int `json:"cover_height"` // 封面自然高(0=未知)
Published *bool `json:"published"`
SortOrder *int `json:"sort_order"`
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
}
func (in *LibraryInput) normalize() error {
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
if in.Slug == "" {
return errors.New("slug 不能为空")
}
if len(in.Slug) > 64 {
return errors.New("slug 不能超过 64 字符")
}
if !sitePageSlugRe.MatchString(in.Slug) {
return errors.New("slug 仅允许小写字母、数字与连字符")
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
return errors.New("标题不能为空")
}
if utf8.RuneCountInString(in.Title) > 200 {
return errors.New("标题不能超过 200 字")
}
in.Author = strings.TrimSpace(in.Author)
if utf8.RuneCountInString(in.Author) > 100 {
return errors.New("作者不能超过 100 字")
}
in.Description = strings.TrimSpace(in.Description)
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
return errors.New("介绍不能超过 20000 字")
}
in.CoverURL = strings.TrimSpace(in.CoverURL)
if utf8.RuneCountInString(in.CoverURL) > 512 {
return errors.New("封面地址过长")
}
return nil
}
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
d.Slug = in.Slug
d.Title = in.Title
d.Author = in.Author
d.Description = in.Description
d.CoverURL = in.CoverURL
// 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测)
if in.CoverURL == "" {
d.CoverWidth = 0
d.CoverHeight = 0
} else {
d.CoverWidth = in.CoverWidth
d.CoverHeight = in.CoverHeight
}
if in.Published != nil {
d.Published = *in.Published
}
if in.SortOrder != nil {
d.SortOrder = *in.SortOrder
}
if in.EntriesAuto != nil {
d.EntriesAuto = *in.EntriesAuto
}
}
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
type LibraryService struct {
db *gorm.DB
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
uploadsDir string // data/uploads:封面回填时解析本地封面图
setting *SettingService
}
func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService {
return &LibraryService{db: db, dir: filepath.Join(dataDir, "library")}
}
// WithSetting 注入站点设置(文件大小上限复用附件设置)
func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
s.setting = setting
return s
}
// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件)
func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService {
s.uploadsDir = uploadsDir
return s
}
func (s *LibraryService) EnsureDir() error {
return os.MkdirAll(s.dir, 0o755)
}
func (s *LibraryService) FilePath(f *model.LibraryFile) string {
return filepath.Join(s.dir, f.StoredName)
}
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
// active=在用条目占用;deleted=已删除条目占用
func (s *LibraryService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
q := s.db.Unscoped().Model(&model.LibraryDoc{}).Where("slug = ?", slug).Select("deleted_at")
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var rows []struct {
DeletedAt gorm.DeletedAt
}
if err := q.Find(&rows).Error; err != nil {
return false, false, err
}
for _, r := range rows {
if r.DeletedAt.Valid {
deleted = true
} else {
active = true
}
}
return active, deleted, nil
}
// ---------- 条目:管理端 ----------
// ListAll 管理后台全部条目(含未发布与已软删,带文件;已删条目仅供查看与彻底删除)
func (s *LibraryService) ListAll() ([]LibraryDocDetail, error) {
var docs []model.LibraryDoc
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(200).Find(&docs).Error; err != nil {
return nil, err
}
return s.attachFiles(docs), nil
}
// Get 管理后台按 ID 取条目
func (s *LibraryService) Get(id uint) (*LibraryDocDetail, error) {
var d model.LibraryDoc
if err := s.db.First(&d, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}
func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail {
out := make([]LibraryDocDetail, 0, len(docs))
if len(docs) == 0 {
return out
}
ids := make([]uint, 0, len(docs))
for _, d := range docs {
ids = append(ids, d.ID)
}
var files []model.LibraryFile
if err := s.db.Where("doc_id IN ?", ids).
Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
files = nil
}
byDoc := make(map[uint][]model.LibraryFile, len(docs))
for _, f := range files {
byDoc[f.DocID] = append(byDoc[f.DocID], f)
}
var sections []model.LibrarySection
if err := s.db.Where("doc_id IN ?", ids).
Order("sort_order ASC, id ASC").Find(&sections).Error; err != nil {
sections = nil
}
secByDoc := make(map[uint][]model.LibrarySection, len(docs))
for _, sec := range sections {
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
}
creatorByID := s.creatorMap(docs)
for _, d := range docs {
fs := byDoc[d.ID]
if fs == nil {
fs = []model.LibraryFile{}
}
ss := secByDoc[d.ID]
if ss == nil {
ss = []model.LibrarySection{}
}
out = append(out, LibraryDocDetail{
LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss,
Creator: creatorByID[d.CreatorID],
})
}
return out
}
// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省)
func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator {
ids := make([]uint, 0, len(docs))
seen := map[uint]bool{}
for _, d := range docs {
if d.CreatorID > 0 && !seen[d.CreatorID] {
seen[d.CreatorID] = true
ids = append(ids, d.CreatorID)
}
}
if len(ids) == 0 {
return map[uint]*LibraryCreator{}
}
var users []model.User
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
return map[uint]*LibraryCreator{}
}
m := make(map[uint]*LibraryCreator, len(users))
for i := range users {
u := &users[i]
m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
}
return m
}
func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*model.LibraryDoc, error) {
if err := in.normalize(); err != nil {
return nil, err
}
active, deleted, err := s.slugTaken(in.Slug, 0)
if err != nil {
return nil, err
}
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
d := &model.LibraryDoc{Published: false, SortOrder: 0}
if len(creatorID) > 0 {
d.CreatorID = creatorID[0]
}
in.applyTo(d)
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return nil, err
}
return d, nil
}
func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, error) {
var d model.LibraryDoc
if err := s.db.First(&d, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
if err := in.normalize(); err != nil {
return nil, err
}
active, deleted, err := s.slugTaken(in.Slug, id)
if err != nil {
return nil, err
}
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
}
in.applyTo(&d)
// map 更新确保 false/空串写入(同 SitePage.Update);creator_id 创建后不变,不在此列
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"author": d.Author,
"description": d.Description,
"cover_url": d.CoverURL,
"cover_width": d.CoverWidth,
"cover_height": d.CoverHeight,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
}).Error; err != nil {
return nil, err
}
return &d, nil
}
// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串
func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) {
rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/")
if rel == "" || rel == coverURL || strings.Contains(rel, "..") {
return "", false
}
abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel))
root := filepath.Clean(s.uploadsDir)
clean := filepath.Clean(abs)
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
return "", false
}
return clean, true
}
// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码)
func measureCoverReader(r io.Reader) (w, h int, err error) {
data, err := io.ReadAll(r)
if err != nil {
return 0, 0, err
}
format, err := detectImageFormat(data)
if err != nil {
return 0, 0, err
}
return decodeImageSizeReader(bytes.NewReader(data), format.mime)
}
// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。
// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。
func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) {
if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" {
return
}
url := strings.TrimSpace(d.CoverURL)
var reader io.ReadCloser
switch {
case strings.HasPrefix(url, "/uploads/"):
abs, ok := s.resolveCoverPath(url)
if !ok {
return
}
f, err := os.Open(abs)
if err != nil {
return
}
reader = f
case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"):
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return
}
reader = resp.Body
default:
return
}
w, h, err := measureCoverReader(reader)
reader.Close()
if err != nil || w <= 0 || h <= 0 {
return
}
if err := s.db.Model(d).Updates(map[string]interface{}{
"cover_width": w,
"cover_height": h,
}).Error; err != nil {
return
}
d.CoverWidth, d.CoverHeight = w, h
}
func (s *LibraryService) Delete(id uint) error {
result := s.db.Delete(&model.LibraryDoc{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrLibraryNotFound
}
return nil
}
// Purge 彻底删除已软删的条目(章节/文件行与磁盘文件一并清除,slug 释放可复用)
func (s *LibraryService) Purge(id uint) error {
var d model.LibraryDoc
if err := s.db.Unscoped().First(&d, id).Error; err != nil {
return ErrLibraryNotFound
}
if !d.DeletedAt.Valid {
return ErrLibraryNotDeleted
}
// 先取出文件清单(含此前单独软删的文件行),事务成功后统一删磁盘
var files []model.LibraryFile
if err := s.db.Unscoped().Where("doc_id = ?", id).Find(&files).Error; err != nil {
return err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibraryFile{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&model.LibraryDoc{}, id).Error
})
if err != nil {
return err
}
// 事务外尽力删除磁盘文件(同 DeleteFile 惯例,失败不影响返回)
for i := range files {
_ = os.Remove(s.FilePath(&files[i]))
}
return nil
}
// ---------- 条目:公开端 ----------
// ListPublished 已发布条目目录(含文件概要,不含 description 全文)
func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
details, err := s.publishedDetails()
if err != nil {
return nil, err
}
out := make([]LibraryDocListItem, 0, len(details))
for _, d := range details {
out = append(out, LibraryDocListItem{
ID: d.ID, Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description,
CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
Creator: d.Creator,
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
})
}
return out, nil
}
// GetPublishedBySlug 公开详情(仅已发布)
func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
var d model.LibraryDoc
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
return nil, ErrLibraryNotFound
}
// 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁)
s.ensureCoverDimensions(&d)
out := s.attachFiles([]model.LibraryDoc{d})
return &out[0], nil
}
func (s *LibraryService) publishedDetails() ([]LibraryDocDetail, error) {
var docs []model.LibraryDoc
if err := s.db.Where("published = ?", true).
Order("sort_order ASC, id ASC").Limit(100).Find(&docs).Error; err != nil {
return nil, err
}
return s.attachFiles(docs), nil
}
// ---------- 文件 ----------
func (s *LibraryService) maxBytes() int64 {
if s.setting == nil {
return FileMaxBytes
}
n, err := s.setting.AttachmentMaxBytes()
if err != nil || n < 1 {
return FileMaxBytes
}
return n
}
// AddFile 上传文件到条目(落盘 data/library + 建行)
func (s *LibraryService) AddFile(docID, userID uint, originalName string, src io.Reader) (*model.LibraryFile, error) {
if src == nil {
return nil, errors.New("文件为空")
}
var doc model.LibraryDoc
if err := s.db.First(&doc, docID).Error; err != nil {
return nil, ErrLibraryNotFound
}
name := sanitizeFilename(originalName)
if name == "" {
name = "file"
}
ext := strings.TrimPrefix(ExtOfFilename(name), ".")
if !LibraryExtAllowed(ext) {
return nil, ErrLibraryExtDenied
}
var count int64
if err := s.db.Model(&model.LibraryFile{}).Where("doc_id = ?", docID).Count(&count).Error; err != nil {
return nil, err
}
if count >= MaxLibraryFilesPerDoc {
return nil, ErrLibraryTooMany
}
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
stored := hex.EncodeToString(raw) + "." + ext
full := filepath.Join(s.dir, stored)
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return nil, err
}
maxB := s.maxBytes()
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(full)
return nil, copyErr
}
if written == 0 {
_ = os.Remove(full)
return nil, errors.New("文件为空")
}
if written > maxB {
_ = os.Remove(full)
mb := int(maxB >> 20)
if mb < 1 {
mb = 1
}
return nil, errors.New("文件过大:不能超过 " + strconv.Itoa(mb) + "MB")
}
file := &model.LibraryFile{
DocID: docID,
UserID: userID,
Name: name,
StoredName: stored,
Ext: ext,
MIME: MimeForAttachmentExt("." + ext),
Size: int(written),
}
if err := s.db.Create(file).Error; err != nil {
_ = os.Remove(full)
return nil, err
}
return file, nil
}
// DeleteFile 删除文件(软删行 + 磁盘尽力删除)
func (s *LibraryService) DeleteFile(id uint) error {
var f model.LibraryFile
if err := s.db.First(&f, id).Error; err != nil {
return ErrLibraryNotFound
}
if err := s.db.Delete(&f).Error; err != nil {
return err
}
_ = os.Remove(s.FilePath(&f))
return nil
}
// GetPublicFile 公开取文件(要求所属条目存在且已发布;软删条目的文件不可达)
func (s *LibraryService) GetPublicFile(id uint) (*model.LibraryFile, error) {
var f model.LibraryFile
if err := s.db.First(&f, id).Error; err != nil {
return nil, ErrLibraryNotFound
}
if f.DocID == 0 {
return nil, ErrLibraryNotFound
}
var n int64
if err := s.db.Model(&model.LibraryDoc{}).
Where("id = ? AND published = ?", f.DocID, true).Count(&n).Error; err != nil {
return nil, err
}
if n == 0 {
return nil, ErrLibraryNotFound
}
return &f, nil
}
// IncrDownload 下载计数原子递增
func (s *LibraryService) IncrDownload(id uint) {
s.db.Model(&model.LibraryFile{}).Where("id = ?", id).
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
}

View File

@@ -0,0 +1,369 @@
package service
// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面),
// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。
//
// 单本 ZIP 布局:
//
// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录)
// cover.<ext> 本地封面(外链封面不内嵌,仅保留 cover_url)
// files/<stored-name> 附件原文件
//
// 全库 ZIP 布局:
//
// library.json 索引(每本书的目录位置)
// docs/<slug>/book.json 各书清单
// docs/<slug>/cover.<ext>
// docs/<slug>/files/<stored-name>
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
)
const (
libraryExportFormat = "jiang13-library" // 全库包
libraryExportBookFormat = "jiang13-library-book" // 单本书包
libraryExportVersion = 1
)
// 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致)
var libraryExportCoverExts = map[string]struct{}{
"jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {},
}
// libraryExportFile 附件清单条目
type libraryExportFile struct {
Name string `json:"name"` // 原始文件名
Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空)
Ext string `json:"ext"`
MIME string `json:"mime"`
Size int `json:"size"`
DownloadCount int `json:"download_count"`
SortOrder int `json:"sort_order"`
Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失
}
// libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key)
type libraryExportSection struct {
Key string `json:"key"`
ParentKey string `json:"parent_key,omitempty"`
Title string `json:"title"`
Content string `json:"content"`
SortOrder int `json:"sort_order"`
}
// libraryExportBook 单本书的完整清单
type libraryExportBook struct {
ID uint `json:"id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径
CoverWidth int `json:"cover_width"`
CoverHeight int `json:"cover_height"`
Published bool `json:"published"`
SortOrder int `json:"sort_order"`
EntriesAuto bool `json:"entries_auto"`
Files []libraryExportFile `json:"files"`
Sections []libraryExportSection `json:"sections"`
}
// libraryExportIndexEntry 全库索引中的单本概要
type libraryExportIndexEntry struct {
Slug string `json:"slug"`
Title string `json:"title"`
Dir string `json:"dir"` // 相对 ZIP 根的目录
FileCount int `json:"file_count"`
SectionCount int `json:"section_count"`
}
// libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引
type libraryExportManifest struct {
Format string `json:"format"`
FormatVersion int `json:"format_version"`
ExportedAt string `json:"exported_at"`
Doc *libraryExportBook `json:"doc,omitempty"`
Docs []libraryExportIndexEntry `json:"docs,omitempty"`
}
// preparedFile 已打开、待流式拷入 ZIP 的附件
type preparedFile struct {
zipPath string
reader *os.File
}
// preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄)
type preparedBook struct {
meta libraryExportBook
dir string // ZIP 内目录前缀(单本包为空串 = 根目录)
coverPath string // ZIP 内封面路径(空 = 不内嵌)
coverReader *os.File
files []preparedFile // 仅磁盘存在的附件
}
func (b *preparedBook) closeReaders() {
if b.coverReader != nil {
_ = b.coverReader.Close()
}
for i := range b.files {
_ = b.files[i].reader.Close()
}
}
// LibraryExport 已完成装载、可直接流式写出的导出包
type LibraryExport struct {
Filename string
full bool
exportedAt time.Time
books []preparedBook
}
// Close 释放导出过程中打开的全部文件句柄
func (e *LibraryExport) Close() {
for i := range e.books {
e.books[i].closeReaders()
}
}
// BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。
// 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。
func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) {
d, err := s.Get(id)
if err != nil {
return nil, err
}
b, err := s.prepareBook(d, "")
if err != nil {
return nil, err
}
name := d.Slug
if name == "" {
name = "book"
}
return &LibraryExport{
Filename: name + ".zip",
full: false,
exportedAt: time.Now(),
books: []preparedBook{*b},
}, nil
}
// BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序)
func (s *LibraryService) BuildAllExport() (*LibraryExport, error) {
var docs []model.LibraryDoc
if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil {
return nil, err
}
details := s.attachFiles(docs)
exp := &LibraryExport{
Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip",
full: true,
exportedAt: time.Now(),
books: make([]preparedBook, 0, len(details)),
}
for i := range details {
// slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底
dir := "docs/" + details[i].Slug + "/"
if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) {
exp.Close()
return nil, errors.New("条目 slug 含非法字符,无法导出")
}
b, err := s.prepareBook(&details[i], dir)
if err != nil {
exp.Close()
return nil, err
}
exp.books = append(exp.books, *b)
}
return exp, nil
}
// prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀)
func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) {
b := &preparedBook{dir: dir}
b.meta = libraryExportBook{
ID: d.ID,
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
Slug: d.Slug,
Title: d.Title,
Author: d.Author,
Description: d.Description,
CoverURL: d.CoverURL,
CoverWidth: d.CoverWidth,
CoverHeight: d.CoverHeight,
Published: d.Published,
SortOrder: d.SortOrder,
EntriesAuto: d.EntriesAuto,
Files: make([]libraryExportFile, 0, len(d.Files)),
Sections: make([]libraryExportSection, 0, len(d.Sections)),
}
// 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序)
keyByID := make(map[uint]string, len(d.Sections))
for i, sec := range d.Sections {
keyByID[sec.ID] = "s" + strconv.Itoa(i)
}
for _, sec := range d.Sections {
m := libraryExportSection{
Key: keyByID[sec.ID],
Title: sec.Title,
Content: sec.Content,
SortOrder: sec.SortOrder,
}
if sec.ParentID != nil {
m.ParentKey = keyByID[*sec.ParentID]
}
b.meta.Sections = append(b.meta.Sections, m)
}
// 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径
for _, f := range d.Files {
m := libraryExportFile{
Name: f.Name,
Ext: f.Ext,
MIME: f.MIME,
Size: f.Size,
DownloadCount: f.DownloadCount,
SortOrder: f.SortOrder,
}
full := filepath.Join(s.dir, f.StoredName)
if r, err := os.Open(full); err == nil {
m.Stored = "files/" + f.StoredName
b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r})
} else {
m.Missing = true
}
b.meta.Files = append(b.meta.Files, m)
}
// 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL
if rel, ok := s.resolveCoverPath(d.CoverURL); ok {
if ext := coverExtOf(d.CoverURL); ext != "" {
if r, err := os.Open(rel); err == nil {
b.coverReader = r
b.coverPath = dir + "cover." + ext
b.meta.CoverFile = "cover." + ext
}
}
}
return b, nil
}
// WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。
func (e *LibraryExport) WriteZip(w io.Writer) error {
defer e.Close()
zw := zip.NewWriter(w)
index := make([]libraryExportIndexEntry, 0, len(e.books))
for i := range e.books {
b := &e.books[i]
// 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写
if e.full {
if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil {
return err
}
}
if b.coverReader != nil {
if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil {
return err
}
}
for j := range b.files {
if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil {
return err
}
}
index = append(index, libraryExportIndexEntry{
Slug: b.meta.Slug,
Title: b.meta.Title,
Dir: b.dir,
FileCount: len(b.meta.Files),
SectionCount: len(b.meta.Sections),
})
}
// 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内)
if e.full {
root := libraryExportManifest{
Format: libraryExportFormat,
FormatVersion: libraryExportVersion,
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
Docs: index,
}
if err := writeZipJSON(zw, "library.json", root); err != nil {
return err
}
} else {
root := libraryExportManifest{
Format: libraryExportBookFormat,
FormatVersion: libraryExportVersion,
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
Doc: &e.books[0].meta,
}
if err := writeZipJSON(zw, "book.json", root); err != nil {
return err
}
}
return zw.Close()
}
// coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名
func coverExtOf(coverURL string) string {
u := strings.TrimSpace(coverURL)
if i := strings.IndexByte(u, '?'); i >= 0 {
u = u[:i]
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), "."))
if _, ok := libraryExportCoverExts[ext]; !ok {
return ""
}
return ext
}
// writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读)
func writeZipJSON(zw *zip.Writer, name string, v any) error {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return err
}
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
hdr.SetMode(0o644)
f, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
_, err = f.Write(buf.Bytes())
return err
}
// writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU)
func writeZipFile(zw *zip.Writer, name string, r io.Reader) error {
hdr := &zip.FileHeader{Name: name, Method: zip.Store}
hdr.SetMode(0o644)
f, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
_, err = io.Copy(f, r)
return err
}

View File

@@ -0,0 +1,199 @@
package service
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"testing"
)
// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false)
func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) {
t.Helper()
for _, f := range zr.File {
if f.Name == name {
rc, err := f.Open()
if err != nil {
t.Fatalf("open zip entry %s: %v", name, err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read zip entry %s: %v", name, err)
}
return data, true
}
}
return nil, false
}
func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader {
t.Helper()
var buf bytes.Buffer
if err := exp.WriteZip(&buf); err != nil {
t.Fatalf("write zip: %v", err)
}
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("read exported zip: %v", err)
}
return zr
}
// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍
func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) {
t.Helper()
pub := true
doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"})
if err != nil {
t.Fatalf("create doc: %v", err)
}
coverDir := filepath.Join(uploads, "images")
if err := os.MkdirAll(coverDir, 0o755); err != nil {
t.Fatalf("mkdir uploads: %v", err)
}
coverBytes := encodeJPEGBytes(t, 120, 160)
if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil {
t.Fatalf("write cover: %v", err)
}
if _, err := s.Update(doc.ID, &LibraryInput{
Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍",
CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160,
Published: &pub,
}); err != nil {
t.Fatalf("update cover: %v", err)
}
chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"})
if err != nil {
t.Fatalf("create chapter: %v", err)
}
if _, err := s.CreateSection(doc.ID, &SectionInput{
ParentID: &chapter.ID, Title: "小节", Content: "小节正文",
}); err != nil {
t.Fatalf("create section: %v", err)
}
f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES")))
if err != nil {
t.Fatalf("add file: %v", err)
}
return doc.ID, f.StoredName
}
func TestLibraryExportBook(t *testing.T) {
s, _ := newLibraryTestService(t)
uploads := t.TempDir()
s.WithUploadsDir(uploads)
docID, storedName := seedBookForExport(t, s, uploads)
exp, err := s.BuildBookExport(docID)
if err != nil {
t.Fatalf("build export: %v", err)
}
defer exp.Close()
if exp.Filename != "dao-jia.zip" {
t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename)
}
zr := buildExportZip(t, exp)
raw, ok := readZipEntry(t, zr, "book.json")
if !ok {
t.Fatal("缺少 book.json")
}
var manifest libraryExportManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatalf("parse book.json: %v", err)
}
if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion {
t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion)
}
b := manifest.Doc
if b == nil {
t.Fatal("book.json 缺少 doc")
}
if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published {
t.Fatalf("元信息导出不正确: %+v", b)
}
if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 {
t.Fatalf("封面信息导出不正确: %+v", b)
}
if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" {
t.Fatalf("章节树导出不正确: %+v", b.Sections)
}
if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing {
t.Fatalf("附件清单导出不正确: %+v", b.Files)
}
if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" {
t.Fatalf("附件内容导出不正确(ok=%v)", ok)
}
if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
t.Fatalf("封面内容导出不正确(ok=%v)", ok)
}
// 单本包不应出现全库索引
if _, ok := readZipEntry(t, zr, "library.json"); ok {
t.Fatal("单本包不应包含 library.json")
}
}
func TestLibraryExportAll(t *testing.T) {
s, _ := newLibraryTestService(t)
uploads := t.TempDir()
s.WithUploadsDir(uploads)
docID, storedName := seedBookForExport(t, s, uploads)
_ = docID
exp, err := s.BuildAllExport()
if err != nil {
t.Fatalf("build all export: %v", err)
}
defer exp.Close()
zr := buildExportZip(t, exp)
raw, ok := readZipEntry(t, zr, "library.json")
if !ok {
t.Fatal("缺少 library.json")
}
var manifest libraryExportManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatalf("parse library.json: %v", err)
}
if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 {
t.Fatalf("全库索引异常: %+v", manifest)
}
idx := manifest.Docs[0]
if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 {
t.Fatalf("索引条目异常: %+v", idx)
}
bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json")
if !ok {
t.Fatal("缺少 docs/dao-jia/book.json")
}
var book libraryExportBook
if err := json.Unmarshal(bookRaw, &book); err != nil {
t.Fatalf("parse nested book.json: %v", err)
}
if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" {
t.Fatalf("嵌套书籍清单异常: %+v", book)
}
if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok {
t.Fatal("缺少嵌套附件")
}
if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok {
t.Fatal("缺少嵌套封面")
}
}
func TestLibraryExportMissingDoc(t *testing.T) {
s, _ := newLibraryTestService(t)
if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) {
t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err)
}
}

View File

@@ -0,0 +1,631 @@
package service
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
//
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
import (
"archive/zip"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
const LibraryImportMaxBytes = 512 << 20
const (
LibraryImportModeCreate = "create" // 冲突跳过
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
)
// 失败原因(前端据此区分冲突,可提示覆盖重导)
const (
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
libraryImportReasonInvalid = "invalid" // 清单数据非法
libraryImportReasonFailed = "failed" // 落盘/写库失败
)
var (
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
ErrLibraryImportVer = errors.New("导出版本不受支持")
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
ErrLibraryImportMode = errors.New("无效的导入模式")
)
// LibraryImportItem 成功导入的单本结果
type LibraryImportItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Action string `json:"action"` // created / overwritten
Sections int `json:"sections"`
Files int `json:"files"`
}
// LibraryImportFailure 单本书失败明细
type LibraryImportFailure struct {
Slug string `json:"slug"`
Title string `json:"title"`
Reason string `json:"reason"`
Error string `json:"error"`
}
// LibraryImportReport 导入报告
type LibraryImportReport struct {
Mode string `json:"mode"`
Total int `json:"total"`
Imported []LibraryImportItem `json:"imported"`
Failed []LibraryImportFailure `json:"failed"`
}
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
type bookImportPlan struct {
book libraryExportBook
dir string
}
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
type plannedSection struct {
key string
parentKey string
title string
content string
sort int
}
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
return nil, ErrLibraryImportMode
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, ErrLibraryImportBadZip
}
defer zr.Close()
entries := make(map[string]*zip.File, len(zr.File))
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
}
plans, err := parseImportPlans(entries)
if err != nil {
return nil, err
}
if len(plans) == 0 {
return nil, ErrLibraryImportEmpty
}
rep := &LibraryImportReport{
Mode: mode,
Total: len(plans),
Imported: []LibraryImportItem{},
Failed: []LibraryImportFailure{},
}
imp := &bookImporter{s: s, entries: entries, userID: userID}
for _, p := range plans {
item, fail := imp.run(p, mode)
if fail != nil {
rep.Failed = append(rep.Failed, *fail)
} else {
rep.Imported = append(rep.Imported, *item)
}
}
return rep, nil
}
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
if rootRaw, ok := entries["library.json"]; ok {
var root libraryExportManifest
if err := readZipJSON(rootRaw, &root); err != nil {
return nil, ErrLibraryImportFormat
}
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
return nil, ErrLibraryImportVer
}
plans := make([]bookImportPlan, 0, len(root.Docs))
for _, d := range root.Docs {
dir := strings.ReplaceAll(d.Dir, "\\", "/")
if !safeImportDir(dir) {
return nil, ErrLibraryImportFormat
}
raw, ok := entries[path.Clean(dir)+"/book.json"]
if !ok {
return nil, ErrLibraryImportFormat
}
var b libraryExportBook
if err := readZipJSON(raw, &b); err != nil {
return nil, ErrLibraryImportFormat
}
plans = append(plans, bookImportPlan{book: b, dir: dir})
}
return plans, nil
}
if rootRaw, ok := entries["book.json"]; ok {
var root libraryExportManifest
if err := readZipJSON(rootRaw, &root); err != nil {
return nil, ErrLibraryImportFormat
}
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
return nil, ErrLibraryImportVer
}
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
}
return nil, ErrLibraryImportFormat
}
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
func safeImportDir(dir string) bool {
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
return false
}
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
return false
}
return sitePageSlugRe.MatchString(slug)
}
// bookImporter 携带一次批量导入的共享上下文
type bookImporter struct {
s *LibraryService
entries map[string]*zip.File
userID uint
}
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
}
// run 导入单本书;返回 item 或 failure(二者互斥)
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
b := p.book
in, err := buildImportInput(&b)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
sections, err := buildImportSections(&b)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
if err := imp.checkPlannedFiles(p); err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
coverEntry, err := imp.planCover(p)
if err != nil {
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
}
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
}
var docID uint
action := "created"
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
switch {
case active && mode != LibraryImportModeOverwrite:
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
case active:
var existing model.LibraryDoc
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
}
docID = existing.ID
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
backfillCreator = existing.CreatorID == 0
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
oldCoverDisk = disk
}
action = "overwritten"
case deleted:
return nil, imp.fail(p, libraryImportReasonDeleted,
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
}
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
coverDisk := ""
if coverEntry != nil {
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
if ferr != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
}
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
}
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
if action == "created" {
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
in.applyTo(d)
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
return err
}
docID = d.ID
} else {
var d model.LibraryDoc
if err := tx.First(&d, docID).Error; err != nil {
return err
}
in.applyTo(&d)
updates := map[string]interface{}{
"slug": d.Slug,
"title": d.Title,
"author": d.Author,
"description": d.Description,
"cover_url": d.CoverURL,
"cover_width": d.CoverWidth,
"cover_height": d.CoverHeight,
"published": d.Published,
"sort_order": d.SortOrder,
"entries_auto": d.EntriesAuto,
}
if backfillCreator {
updates["creator_id"] = imp.userID
}
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
return err
}
}
return imp.createSections(tx, docID, sections)
})
if txErr != nil {
if action == "created" && coverDisk != "" {
_ = os.Remove(coverDisk)
}
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
}
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
for i := range oldFiles {
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
}
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
_ = os.Remove(oldCoverDisk)
}
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
added := 0
var newFiles []model.LibraryFile
for _, mf := range b.Files {
if mf.Missing || mf.Stored == "" {
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
}
f, fail := imp.addPlannedFile(p, docID, mf)
if fail != nil {
if action == "created" {
imp.rollbackCreated(docID, newFiles, coverDisk)
}
return nil, fail
}
newFiles = append(newFiles, *f)
added++
}
return &LibraryImportItem{
Slug: b.Slug,
Title: b.Title,
Action: action,
Sections: len(sections),
Files: added,
}, nil
}
// ---------- 清单解析与预检 ----------
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
in := &LibraryInput{
Slug: b.Slug,
Title: b.Title,
Author: b.Author,
Description: b.Description,
CoverURL: b.CoverURL,
CoverWidth: b.CoverWidth,
CoverHeight: b.CoverHeight,
Published: &pub,
SortOrder: &sortOrder,
EntriesAuto: &entriesAuto,
}
if err := in.normalize(); err != nil {
return nil, err
}
return in, nil
}
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
if len(b.Sections) > MaxSectionsPerDoc {
return nil, errors.New("章节数量超过上限(最多 200)")
}
keySeen := map[string]bool{}
out := make([]plannedSection, 0, len(b.Sections))
chapterSort := map[string]int{}
order := 0
for _, sec := range b.Sections {
if sec.ParentKey != "" {
continue
}
title := strings.TrimSpace(sec.Title)
if title == "" {
return nil, errors.New("存在标题为空的章节")
}
if err := validateSectionText(title, sec.Content); err != nil {
return nil, err
}
if keySeen[sec.Key] {
return nil, errors.New("章节标识重复:" + sec.Key)
}
keySeen[sec.Key] = true
chapterSort[sec.Key] = order
out = append(out, plannedSection{
key: sec.Key, title: title, content: sec.Content, sort: order,
})
order++
}
childCount := map[string]int{}
for _, sec := range b.Sections {
if sec.ParentKey == "" {
continue
}
if _, ok := chapterSort[sec.ParentKey]; !ok {
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
}
title := strings.TrimSpace(sec.Title)
if title == "" {
return nil, errors.New("存在标题为空的小节")
}
if err := validateSectionText(title, sec.Content); err != nil {
return nil, err
}
out = append(out, plannedSection{
key: sec.Key,
parentKey: sec.ParentKey,
title: title,
content: sec.Content,
sort: childCount[sec.ParentKey],
})
childCount[sec.ParentKey]++
}
return out, nil
}
func validateSectionText(title, content string) error {
if len([]rune(title)) > 200 {
return errors.New("章节标题不能超过 200 字:" + title)
}
if len([]rune(content)) > MaxSectionContent {
return errors.New("章节正文不能超过 100000 字:" + title)
}
return nil
}
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
if len(p.book.Files) > MaxLibraryFilesPerDoc {
return errors.New("附件数量超过上限(最多 20 个)")
}
maxB := imp.s.maxBytes()
for _, f := range p.book.Files {
if f.Missing || f.Stored == "" {
continue
}
rel, ok := cleanZipRel(f.Stored)
if !ok || !strings.HasPrefix(rel, "files/") {
return errors.New("附件路径非法:" + f.Stored)
}
name := sanitizeFilename(f.Name)
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
return errors.New("附件格式不受支持:" + f.Name)
}
zf, ok := imp.entries[p.dir+rel]
if !ok {
return errors.New("备份包缺少附件文件:" + f.Name)
}
if zf.UncompressedSize64 > uint64(maxB) {
return errors.New("附件超过大小上限:" + f.Name)
}
}
return nil
}
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
cf := strings.TrimSpace(p.book.CoverFile)
if cf == "" {
return nil, nil
}
rel, ok := cleanZipRel(cf)
if !ok || strings.Contains(rel, "/") {
return nil, errors.New("封面路径非法:" + cf)
}
zf, ok := imp.entries[p.dir+rel]
if !ok {
return nil, errors.New("备份包缺少封面文件")
}
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
return nil, errors.New("封面不能超过 5MB")
}
return zf, nil
}
// ---------- 落盘 ----------
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
keyToID := make(map[string]uint, len(sections))
for i := range sections {
ps := &sections[i]
if ps.parentKey != "" {
continue
}
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
if err := tx.Create(sec).Error; err != nil {
return err
}
keyToID[ps.key] = sec.ID
}
for i := range sections {
ps := &sections[i]
if ps.parentKey == "" {
continue
}
parentID, ok := keyToID[ps.parentKey]
if !ok {
return errors.New("小节找不到所属章节")
}
sec := &model.LibrarySection{
DocID: docID, ParentID: &parentID,
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
}
if err := tx.Create(sec).Error; err != nil {
return err
}
}
return nil
}
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
func (imp *bookImporter) addPlannedFile(
p bookImportPlan, docID uint, mf libraryExportFile,
) (*model.LibraryFile, *LibraryImportFailure) {
rel, _ := cleanZipRel(mf.Stored)
zf, ok := imp.entries[p.dir+rel]
if !ok {
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
}
rc, err := zf.Open()
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
}
defer rc.Close()
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
if err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
}
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
UpdateColumns(map[string]interface{}{
"download_count": mf.DownloadCount,
"sort_order": mf.SortOrder,
}).Error; err != nil {
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
}
return f, nil
}
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
if imp.s.uploadsDir == "" {
return "", 0, 0, "", errors.New("未配置上传目录")
}
rc, err := zf.Open()
if err != nil {
return "", 0, 0, "", err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
if err != nil {
return "", 0, 0, "", err
}
if int64(len(data)) > ImageMaxBytes {
return "", 0, 0, "", errors.New("封面不能超过 5MB")
}
format, err := detectImageFormat(data)
if err != nil {
return "", 0, 0, "", err
}
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
if err != nil || width < 1 || height < 1 {
return "", 0, 0, "", errors.New("无法解析封面图片")
}
if width > ImageMaxDim || height > ImageMaxDim {
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return "", 0, 0, "", err
}
filename := hex.EncodeToString(nameBytes) + format.ext
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
return "", 0, 0, "", err
}
full := filepath.Join(imagesDir, filename)
if err := os.WriteFile(full, data, 0o644); err != nil {
return "", 0, 0, "", err
}
return "/uploads/images/" + filename, width, height, full, nil
}
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
for i := range newFiles {
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
}
if coverDisk != "" {
_ = os.Remove(coverDisk)
}
}
// ---------- ZIP 工具 ----------
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
func cleanZipRel(rel string) (string, bool) {
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
return "", false
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return "", false
}
}
return p, true
}
func readZipJSON(zf *zip.File, v any) error {
rc, err := zf.Open()
if err != nil {
return err
}
defer rc.Close()
return json.NewDecoder(rc).Decode(v)
}

View File

@@ -0,0 +1,354 @@
package service
import (
"archive/zip"
"bytes"
"errors"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
)
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
t.Helper()
p := filepath.Join(t.TempDir(), "export.zip")
f, err := os.Create(p)
if err != nil {
t.Fatalf("create temp zip: %v", err)
}
if err := exp.WriteZip(f); err != nil {
t.Fatalf("write export zip: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close temp zip: %v", err)
}
return p
}
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
func writeZipFileMap(t *testing.T, entries map[string]string) string {
t.Helper()
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, content); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
p := filepath.Join(t.TempDir(), "in.zip")
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write temp zip: %v", err)
}
return p
}
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
t.Helper()
if rep == nil {
t.Fatal("报告为空")
}
if rep.Total != total {
t.Fatalf("total = %d, want %d", rep.Total, total)
}
if len(rep.Failed) != 0 {
t.Fatalf("存在失败记录: %+v", rep.Failed)
}
if len(rep.Imported) != total {
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
}
}
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
func TestLibraryImportBookRoundTrip(t *testing.T) {
// ---- 源库 ----
src, _ := newLibraryTestService(t)
srcUploads := t.TempDir()
src.WithUploadsDir(srcUploads)
docID, storedName := seedBookForExport(t, src, srcUploads)
// 制造两次下载,验证计数随包保留
var srcFile model.LibraryFile
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
t.Fatalf("find source file: %v", err)
}
src.IncrDownload(srcFile.ID)
src.IncrDownload(srcFile.ID)
exp, err := src.BuildBookExport(docID)
if err != nil {
t.Fatalf("build export: %v", err)
}
zipPath := writeExportZipFile(t, exp)
exp.Close()
// ---- 目标库(空库)----
dst, _ := newLibraryTestService(t)
dstUploads := t.TempDir()
dst.WithUploadsDir(dstUploads)
// 1) create 导入
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
if err != nil {
t.Fatalf("import: %v", err)
}
assertReportOK(t, rep, 1)
item := rep.Imported[0]
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
t.Fatalf("导入条目异常: %+v", item)
}
var doc model.LibraryDoc
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
t.Fatalf("find imported doc: %v", err)
}
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
t.Fatalf("元信息恢复不正确: %+v", doc)
}
if doc.Author != "老子" {
t.Fatalf("作者恢复不正确: %q", doc.Author)
}
if doc.CreatorID != 7 {
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
}
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
}
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
}
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
if !ok {
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
}
coverData, err := os.ReadFile(coverDisk)
if err != nil {
t.Fatalf("封面未落盘: %v", err)
}
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
t.Fatal("封面内容不是 JPEG")
}
// 章节树:章 + 小节父子关系
secs, err := dst.ListSections(doc.ID)
if err != nil {
t.Fatalf("list sections: %v", err)
}
if len(secs) != 2 {
t.Fatalf("章节数 = %d, want 2", len(secs))
}
var chapter, child *model.LibrarySection
for i := range secs {
if secs[i].ParentID == nil {
chapter = &secs[i]
} else {
child = &secs[i]
}
}
if chapter == nil || child == nil {
t.Fatalf("章节层级不正确: %+v", secs)
}
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
t.Fatalf("章信息不正确: %+v", chapter)
}
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
t.Fatalf("小节信息不正确: %+v", child)
}
// 附件:重新随机落盘、内容与下载计数保留
var files []model.LibraryFile
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
t.Fatalf("list files: %v", err)
}
if len(files) != 1 {
t.Fatalf("附件数 = %d, want 1", len(files))
}
nf := files[0]
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
t.Fatalf("附件恢复不正确: %+v", nf)
}
data, err := os.ReadFile(dst.FilePath(&nf))
if err != nil {
t.Fatalf("附件未落盘: %v", err)
}
if string(data) != "EPUB-FILE-BYTES" {
t.Fatalf("附件内容不正确: %q", data)
}
firstCoverURL := doc.CoverURL
// 2) 再次 create:冲突跳过
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
t.Fatalf("冲突报告异常: %+v", rep2)
}
if rep2.Failed[0].Reason != libraryImportReasonConflict {
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
}
var docCount int64
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
if docCount != 1 {
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
}
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
if err != nil {
t.Fatalf("add extra: %v", err)
}
extraPath := dst.FilePath(extra)
if _, err := os.Stat(extraPath); err != nil {
t.Fatalf("extra 未先落盘: %v", err)
}
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
if err != nil {
t.Fatalf("overwrite import: %v", err)
}
assertReportOK(t, rep3, 1)
if rep3.Imported[0].Action != "overwritten" {
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
}
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
if docCount != 1 {
t.Fatalf("覆盖后书库数量异常: %d", docCount)
}
var doc2 model.LibraryDoc
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
t.Fatalf("find overwritten doc: %v", err)
}
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
}
if doc2.Author != "老子" || doc2.CreatorID != 7 {
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
}
var files2 []model.LibraryFile
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
}
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
}
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
}
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
}
if doc2.CoverURL == firstCoverURL {
t.Fatal("覆盖后封面应重新落盘为新文件")
}
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
if _, err := os.ReadFile(newCoverDisk); err != nil {
t.Fatalf("新封面未落盘: %v", err)
}
}
// TestLibraryImportAll 全库包往返
func TestLibraryImportAll(t *testing.T) {
src, _ := newLibraryTestService(t)
srcUploads := t.TempDir()
src.WithUploadsDir(srcUploads)
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
t.Fatalf("create second doc: %v", err)
}
seedBookForExport(t, src, srcUploads)
exp, err := src.BuildAllExport()
if err != nil {
t.Fatalf("build all export: %v", err)
}
zipPath := writeExportZipFile(t, exp)
exp.Close()
dst, _ := newLibraryTestService(t)
dst.WithUploadsDir(t.TempDir())
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
if err != nil {
t.Fatalf("import all: %v", err)
}
assertReportOK(t, rep, 2)
slugs := map[string]bool{}
for _, it := range rep.Imported {
slugs[it.Slug] = true
}
if !slugs["dao-jia"] || !slugs["second-book"] {
t.Fatalf("导入书集不正确: %+v", slugs)
}
}
// TestLibraryImportRejectBadPackage 非法包整体拒绝
func TestLibraryImportRejectBadPackage(t *testing.T) {
s, _ := newLibraryTestService(t)
s.WithUploadsDir(t.TempDir())
badPath := filepath.Join(t.TempDir(), "bad.zip")
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
}
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
}
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
}
}
// TestCleanZipRel 路径穿越校验
func TestCleanZipRel(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", false},
{".", false},
{"..", false},
{"../evil", false},
{"a/../../b", false},
{"/etc/passwd", false},
{`docs\..\..\x`, false},
{"files/a.epub", true},
{"docs/x/book.json", true},
{`docs\x/cover.jpg`, true},
{"a//b", true},
}
for _, tc := range cases {
_, ok := cleanZipRel(tc.in)
if ok != tc.want {
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
}
}
}

View File

@@ -0,0 +1,422 @@
package service
import (
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 章节约束:两级树(章 → 节),正文为 markdown
const (
MaxSectionsPerDoc = 200 // 单条目章+节总数上限
MaxSectionContent = 100_000 // 单章节正文字符上限
MaxImportBytes = 5 << 20 // 单个 md 文件字节上限(5MB)
MaxImportFiles = 50 // 单次批量导入文件数上限
MaxImportTotalBytes = 20 << 20 // 批量导入总字节上限(20MB)
)
var ErrSectionNotFound = errors.New("章节不存在")
// SectionInput 创建/更新章节入参(更新仅改标题与正文,父子关系由创建时决定、顺序用 move 调整)
type SectionInput struct {
ParentID *uint `json:"parent_id"`
Title string `json:"title"`
Content string `json:"content"`
}
func (in *SectionInput) normalize() error {
if in.ParentID != nil && *in.ParentID == 0 {
in.ParentID = nil
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
return errors.New("章节标题不能为空")
}
if utf8.RuneCountInString(in.Title) > 200 {
return errors.New("章节标题不能超过 200 字")
}
if utf8.RuneCountInString(in.Content) > MaxSectionContent {
return errors.New("章节正文不能超过 100000 字")
}
return nil
}
func (s *LibraryService) section(id uint) (*model.LibrarySection, error) {
var sec model.LibrarySection
if err := s.db.First(&sec, id).Error; err != nil {
return nil, ErrSectionNotFound
}
return &sec, nil
}
func (s *LibraryService) sectionCount(docID uint) (int64, error) {
var n int64
if err := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID).Count(&n).Error; err != nil {
return 0, err
}
return n, nil
}
// sectionMaxSort 同级兄弟的当前最大 sort_order(无兄弟时 -1)
func (s *LibraryService) sectionMaxSort(docID uint, parentID *uint) (int, error) {
q := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID)
if parentID == nil {
q = q.Where("parent_id IS NULL")
} else {
q = q.Where("parent_id = ?", *parentID)
}
var maxSort int
if err := q.Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort).Error; err != nil {
return 0, err
}
return maxSort, nil
}
// ListSections 条目下全部章节(平铺,章在前节按组内序)
func (s *LibraryService) ListSections(docID uint) ([]model.LibrarySection, error) {
var secs []model.LibrarySection
if err := s.db.Where("doc_id = ?", docID).
Order("sort_order ASC, id ASC").Find(&secs).Error; err != nil {
return nil, err
}
return secs, nil
}
// validateParent 两级约束:父必须是同条目下的章(自身不能是节)
func (s *LibraryService) validateParent(docID uint, parentID *uint) error {
if parentID == nil {
return nil
}
parent, err := s.section(*parentID)
if err != nil {
return errors.New("父章节不存在")
}
if parent.DocID != docID {
return errors.New("父章节不属于该条目")
}
if parent.ParentID != nil {
return errors.New("目录最多两级:章下不能再建小节")
}
return nil
}
func (s *LibraryService) CreateSection(docID uint, in *SectionInput) (*model.LibrarySection, error) {
if err := in.normalize(); err != nil {
return nil, err
}
var doc model.LibraryDoc
if err := s.db.First(&doc, docID).Error; err != nil {
return nil, ErrLibraryNotFound
}
if err := s.validateParent(docID, in.ParentID); err != nil {
return nil, err
}
count, err := s.sectionCount(docID)
if err != nil {
return nil, err
}
if count >= MaxSectionsPerDoc {
return nil, errors.New("章节数量超过上限")
}
maxSort, err := s.sectionMaxSort(docID, in.ParentID)
if err != nil {
return nil, err
}
sec := &model.LibrarySection{
DocID: docID,
ParentID: in.ParentID,
Title: in.Title,
Content: in.Content,
SortOrder: maxSort + 1,
}
if err := s.db.Create(sec).Error; err != nil {
return nil, err
}
return sec, nil
}
// UpdateSection 更新标题与正文(父章节与顺序不变)
func (s *LibraryService) UpdateSection(id uint, in *SectionInput) (*model.LibrarySection, error) {
in.ParentID = nil // 忽略传入的 parent:父子关系不在此处变更
if err := in.normalize(); err != nil {
return nil, err
}
sec, err := s.section(id)
if err != nil {
return nil, err
}
if err := s.db.Model(sec).Updates(map[string]interface{}{
"title": in.Title,
"content": in.Content,
}).Error; err != nil {
return nil, err
}
return s.section(id)
}
// DeleteSection 删除章节;若为章则级联软删其下小节
func (s *LibraryService) DeleteSection(id uint) error {
sec, err := s.section(id)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
if sec.ParentID == nil {
if err := tx.Where("parent_id = ?", sec.ID).Delete(&model.LibrarySection{}).Error; err != nil {
return err
}
}
return tx.Delete(sec).Error
})
}
// MoveSection 与同父相邻兄弟交换 sort_order(边界为 no-op)
func (s *LibraryService) MoveSection(id uint, direction string) error {
if direction != "up" && direction != "down" {
return errors.New("无效的移动方向")
}
sec, err := s.section(id)
if err != nil {
return err
}
q := s.db.Where("doc_id = ?", sec.DocID)
if sec.ParentID == nil {
q = q.Where("parent_id IS NULL")
} else {
q = q.Where("parent_id = ?", *sec.ParentID)
}
var sibs []model.LibrarySection
if err := q.Order("sort_order ASC, id ASC").Find(&sibs).Error; err != nil {
return err
}
idx := -1
for i := range sibs {
if sibs[i].ID == sec.ID {
idx = i
break
}
}
if idx < 0 {
return ErrSectionNotFound
}
var other *model.LibrarySection
if direction == "up" && idx > 0 {
other = &sibs[idx-1]
}
if direction == "down" && idx < len(sibs)-1 {
other = &sibs[idx+1]
}
if other == nil {
return nil // 已在边界,无需移动
}
a, b := sec.SortOrder, other.SortOrder
if a == b {
b = a + 1 // 相同排序值时保证交换后顺序确实变化
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.LibrarySection{}).Where("id = ?", sec.ID).
UpdateColumn("sort_order", b).Error; err != nil {
return err
}
return tx.Model(&model.LibrarySection{}).Where("id = ?", other.ID).
UpdateColumn("sort_order", a).Error
})
}
// ---------- md 拆章导入 ----------
type mdChapterChunk struct {
Title string
Body string
}
// splitMarkdownChapters 按指定级别标题("# " 或 "## ")切块;
// 代码围栏内的 # 行不视为标题;首个标题之前的内容并入第一章正文。
func splitMarkdownChapters(content, level string) []mdChapterChunk {
prefix := "# "
if level == "h2" {
prefix = "## "
}
var chunks []mdChapterChunk
var pre strings.Builder
bodies := []strings.Builder{}
cur := -1
inCode := false
text := strings.ReplaceAll(content, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
for _, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "```") {
inCode = !inCode
}
if !inCode && strings.HasPrefix(t, prefix) {
title := strings.TrimSpace(strings.TrimPrefix(t, prefix))
title = strings.TrimSpace(strings.TrimRight(title, "#"))
chunks = append(chunks, mdChapterChunk{Title: title})
bodies = append(bodies, strings.Builder{})
cur = len(chunks) - 1
continue
}
if cur < 0 {
pre.WriteString(line)
pre.WriteString("\n")
} else {
bodies[cur].WriteString(line)
bodies[cur].WriteString("\n")
}
}
if len(chunks) == 0 {
return nil
}
out := make([]mdChapterChunk, len(chunks))
for i := range chunks {
body := bodies[i].String()
if i == 0 && pre.Len() > 0 {
body = pre.String() + body
}
out[i] = mdChapterChunk{Title: chunks[i].Title, Body: strings.TrimSpace(body)}
}
return out
}
// truncateRunes 按 rune 截断
func truncateRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n])
}
// ImportFileInput 批量导入的单个文件
type ImportFileInput struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
// chunksFromFile 把单个文件拆成章块:校验单文件大小,标题回退取文件名;
// splitLevel 为 none 或未命中标题时整篇一章。
func chunksFromFile(filename, content, splitLevel string) (string, []mdChapterChunk, error) {
if len(content) > MaxImportBytes {
return "", nil, errors.New("文件 " + sanitizeFilename(filename) + " 过大:单个文件不能超过 5MB")
}
base := sanitizeFilename(filename)
if i := strings.LastIndex(base, "."); i > 0 {
base = base[:i]
}
base = strings.TrimSpace(base)
if base == "" {
base = "未命名章节"
}
if utf8.RuneCountInString(base) > 200 {
base = truncateRunes(base, 200)
}
var chunks []mdChapterChunk
if splitLevel == "none" {
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
} else {
chunks = splitMarkdownChapters(content, splitLevel)
}
if len(chunks) == 0 {
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
}
return base, chunks, nil
}
// ImportMarkdownBatch 批量导入:多个 md 文件按传入顺序依次拆成顶级章(不生成节)。
// splitLevel 对每个文件生效;无命中标题的文件回退为单章(标题取文件名)。
// 任一文件失败整体回滚。单文件导入即长度为 1 的批量。
func (s *LibraryService) ImportMarkdownBatch(docID uint, files []ImportFileInput, splitLevel string) ([]model.LibrarySection, error) {
var doc model.LibraryDoc
if err := s.db.First(&doc, docID).Error; err != nil {
return nil, ErrLibraryNotFound
}
if splitLevel != "h1" && splitLevel != "h2" && splitLevel != "none" {
return nil, errors.New("无效的拆分方式")
}
if len(files) == 0 {
return nil, errors.New("没有可导入的文件")
}
if len(files) > MaxImportFiles {
return nil, errors.New("单次最多导入 " + strconv.Itoa(MaxImportFiles) + " 个文件")
}
total := 0
for _, f := range files {
total += len(f.Content)
}
if total > MaxImportTotalBytes {
return nil, errors.New("导入内容总量过大:不能超过 20MB")
}
// 逐文件拆块(保留文件边界,便于错误提示定位到文件)
type mdFilePlan struct {
Base string
Chunks []mdChapterChunk
}
plan := make([]mdFilePlan, 0, len(files))
totalChunks := 0
for _, f := range files {
base, chunks, err := chunksFromFile(f.Filename, f.Content, splitLevel)
if err != nil {
return nil, err
}
plan = append(plan, mdFilePlan{Base: base, Chunks: chunks})
totalChunks += len(chunks)
}
count, err := s.sectionCount(docID)
if err != nil {
return nil, err
}
if count+int64(totalChunks) > MaxSectionsPerDoc {
return nil, errors.New("导入后的章节数超过上限(现有 + 导入 ≤ 200)")
}
maxSort, err := s.sectionMaxSort(docID, nil)
if err != nil {
return nil, err
}
created := make([]model.LibrarySection, 0, totalChunks)
err = s.db.Transaction(func(tx *gorm.DB) error {
offset := 0
for _, fp := range plan {
for i, ch := range fp.Chunks {
title := strings.TrimSpace(ch.Title)
if title == "" {
title = fp.Base + "(续)"
}
if utf8.RuneCountInString(title) > 200 {
title = truncateRunes(title, 200)
}
if utf8.RuneCountInString(ch.Body) > MaxSectionContent {
return errors.New("「" + fp.Base + "」第 " + strconv.Itoa(i+1) + " 章正文超过 100000 字,请先拆分后再导入")
}
sec := &model.LibrarySection{
DocID: docID,
Title: title,
Content: ch.Body,
SortOrder: maxSort + 1 + offset + i,
}
if err := tx.Create(sec).Error; err != nil {
return err
}
created = append(created, *sec)
}
offset += len(fp.Chunks)
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// ImportMarkdown 单文件导入(批量为长度 1 的特例,保留旧端点兼容)
func (s *LibraryService) ImportMarkdown(docID uint, filename, content, splitLevel string) ([]model.LibrarySection, error) {
return s.ImportMarkdownBatch(docID, []ImportFileInput{{Filename: filename, Content: content}}, splitLevel)
}

View File

@@ -0,0 +1,559 @@
package service
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newLibraryTestService(t *testing.T) (*LibraryService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewLibraryService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func libPub(b bool) *bool { return &b }
func libInt(v int) *int { return &v }
func TestLibraryNormalize(t *testing.T) {
cases := []struct {
name string
mutate func(*LibraryInput)
wantErr string
}{
{"slug 为空", func(i *LibraryInput) { i.Slug = "" }, "slug 不能为空"},
{"slug 非法", func(i *LibraryInput) { i.Slug = "Bad_Slug" }, "slug 仅允许"},
{"slug 超长", func(i *LibraryInput) { i.Slug = strings.Repeat("a", 65) }, "slug 不能超过"},
{"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"},
{"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"},
{"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"},
{"作者超限", func(i *LibraryInput) { i.Author = strings.Repeat("作", 101) }, "作者不能超过"},
{"合法", func(i *LibraryInput) {}, ""},
}
for _, tc := range cases {
in := &LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"}
tc.mutate(in)
err := in.normalize()
if tc.wantErr == "" {
if err != nil {
t.Fatalf("%s: 不应报错,got %v", tc.name, err)
}
continue
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("%s: 期望包含 %q,got %v", tc.name, tc.wantErr, err)
}
}
}
func TestLibraryAuthorAndCreator(t *testing.T) {
s, _ := newLibraryTestService(t)
user := &model.User{Username: "curator", Nickname: "馆长", Avatar: "/a.png"}
if err := s.db.Create(user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
pub := true
doc, err := s.Create(&LibraryInput{
Slug: "authored", Title: "署名本", Author: " 鲁迅 ", Published: &pub,
}, user.ID)
if err != nil {
t.Fatalf("create: %v", err)
}
if doc.Author != "鲁迅" || doc.CreatorID != user.ID {
t.Fatalf("作者应裁剪、创建者应落库,got %+v", doc)
}
list, err := s.ListPublished()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 || list[0].Author != "鲁迅" {
t.Fatalf("列表作者异常: %+v", list)
}
if c := list[0].Creator; c == nil || c.ID != user.ID || c.Nickname != "馆长" {
t.Fatalf("列表创建者摘要异常: %+v", c)
}
detail, err := s.GetPublishedBySlug("authored")
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.Creator == nil || detail.Creator.Username != "curator" {
t.Fatalf("详情创建者摘要异常: %+v", detail.Creator)
}
// 作者可清空;更新不改变创建者归属
if _, err := s.Update(doc.ID, &LibraryInput{
Slug: "authored", Title: "署名本", Author: "", Published: &pub,
}); err != nil {
t.Fatalf("update: %v", err)
}
var again model.LibraryDoc
if err := s.db.First(&again, doc.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if again.Author != "" || again.CreatorID != user.ID {
t.Fatalf("更新后作者/创建者异常: %+v", again)
}
}
func TestLibrarySlugUnique(t *testing.T) {
s, _ := newLibraryTestService(t)
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil {
t.Fatalf("create a: %v", err)
}
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "B"}); err == nil {
t.Fatal("重复 slug 应报错")
}
}
func TestLibrarySlugReuseAfterSoftDelete(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, err := s.Create(&LibraryInput{Slug: "test", Title: "测试"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := s.Delete(doc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
_, err = s.Create(&LibraryInput{Slug: "test", Title: "重建"})
if err == nil || !strings.Contains(err.Error(), "已删除") {
t.Fatalf("软删后重建应提示已删除条目占用,got %v", err)
}
// 彻底删除后可重建
if err := s.Purge(doc.ID); err != nil {
t.Fatalf("purge: %v", err)
}
if _, err := s.Create(&LibraryInput{Slug: "test", Title: "重建"}); err != nil {
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
}
}
func TestLibraryPurgeRequiresDeleted(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "alive", Title: "在用"})
if err := s.Purge(doc.ID); err == nil || !errors.Is(err, ErrLibraryNotDeleted) {
t.Fatalf("未删条目 Purge 应拒绝,got %v", err)
}
}
func TestLibraryPurgeRemovesFilesAndSections(t *testing.T) {
s, dir := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "purge-me", Title: "待清"})
f, err := s.AddFile(doc.ID, 1, "x.txt", strings.NewReader("x"))
if err != nil {
t.Fatalf("add file: %v", err)
}
full := filepath.Join(dir, "library", f.StoredName)
if _, err := os.Stat(full); err != nil {
t.Fatalf("文件未落盘: %v", err)
}
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}); err != nil {
t.Fatalf("create section: %v", err)
}
if err := s.Delete(doc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if err := s.Purge(doc.ID); err != nil {
t.Fatalf("purge: %v", err)
}
var n int64
s.db.Unscoped().Model(&model.LibraryDoc{}).Count(&n)
if n != 0 {
t.Fatalf("条目行应已清除,got %d", n)
}
s.db.Unscoped().Model(&model.LibraryFile{}).Count(&n)
if n != 0 {
t.Fatalf("文件行应已清除,got %d", n)
}
s.db.Unscoped().Model(&model.LibrarySection{}).Count(&n)
if n != 0 {
t.Fatalf("章节行应已清除,got %d", n)
}
if _, err := os.Stat(full); !os.IsNotExist(err) {
t.Fatalf("磁盘文件应已删除,err=%v", err)
}
}
func TestLibraryExtWhitelists(t *testing.T) {
if !LibraryExtAllowed("epub") || !LibraryExtAllowed(".md") {
t.Fatal("白名单内扩展名应允许")
}
if LibraryExtAllowed("exe") || LibraryExtAllowed("php") || LibraryExtAllowed("") {
t.Fatal("白名单外扩展名应拒绝")
}
}
func TestLibraryAddFile(t *testing.T) {
s, dir := newLibraryTestService(t)
doc, err := s.Create(&LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"})
if err != nil {
t.Fatalf("create: %v", err)
}
// 白名单内:落盘 + 建行
f, err := s.AddFile(doc.ID, 1, "如何生活.pdf", strings.NewReader("PDF-DATA"))
if err != nil {
t.Fatalf("add pdf: %v", err)
}
if f.Ext != "pdf" || f.Size != 8 {
t.Fatalf("文件元数据错误: %+v", f)
}
if f.MIME != "application/pdf" {
t.Fatalf("pdf MIME 应为 application/pdf,got %s", f.MIME)
}
if _, err := os.Stat(filepath.Join(dir, "library", f.StoredName)); err != nil {
t.Fatalf("文件未落盘: %v", err)
}
// html 存储层 MIME 强制 octet-stream(下载安全)
h, err := s.AddFile(doc.ID, 1, "page.html", strings.NewReader("<h1>hi</h1>"))
if err != nil {
t.Fatalf("add html: %v", err)
}
if h.MIME != "application/octet-stream" {
t.Fatalf("html 下载 MIME 应强制 octet-stream,got %s", h.MIME)
}
// 白名单外拒绝
if _, err := s.AddFile(doc.ID, 1, "evil.exe", strings.NewReader("MZ")); err == nil {
t.Fatal("exe 应被拒绝")
}
if _, err := s.AddFile(doc.ID, 1, "noext", strings.NewReader("x")); err == nil {
t.Fatal("无扩展名应被拒绝")
}
// 超过每条目上限
for i := 0; i < MaxLibraryFilesPerDoc-2; i++ {
if _, err := s.AddFile(doc.ID, 1, "f.txt", strings.NewReader("x")); err != nil {
t.Fatalf("add #%d: %v", i, err)
}
}
if _, err := s.AddFile(doc.ID, 1, "over.txt", strings.NewReader("x")); err == nil {
t.Fatal("超出文件数上限应报错")
}
// 未发布条目的文件公开不可见
if _, err := s.GetPublicFile(f.ID); err == nil {
t.Fatal("未发布条目的文件不应公开可见")
}
}
func TestLibraryPublishedFilterAndOrder(t *testing.T) {
s, _ := newLibraryTestService(t)
if _, err := s.Create(&LibraryInput{Slug: "draft-doc", Title: "草稿"}); err != nil {
t.Fatalf("create draft: %v", err)
}
published, _ := s.Create(&LibraryInput{
Slug: "live-doc", Title: "已发布", Published: libPub(true), SortOrder: libInt(1),
})
if _, err := s.AddFile(published.ID, 1, "b.txt", strings.NewReader("b")); err != nil {
t.Fatalf("add b: %v", err)
}
if _, err := s.AddFile(published.ID, 1, "a.txt", strings.NewReader("a")); err != nil {
t.Fatalf("add a: %v", err)
}
// 未发布不出现在公开目录
list, err := s.ListPublished()
if err != nil {
t.Fatalf("ListPublished: %v", err)
}
if len(list) != 1 || list[0].Slug != "live-doc" {
t.Fatalf("公开目录应只含 live-doc,got %+v", list)
}
if list[0].FileCount != 2 {
t.Fatalf("列表应带文件计数 2,got %d", list[0].FileCount)
}
if _, err := s.GetPublishedBySlug("draft-doc"); err == nil {
t.Fatal("未发布条目按 slug 不应可见")
}
// 软删后公开彻底不可见
if err := s.Delete(published.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := s.GetPublishedBySlug("live-doc"); err == nil {
t.Fatal("软删条目不应可见")
}
// 管理端仍可见(含未发布;软删条目带 Deleted 标记一并列出)
all, err := s.ListAll()
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(all) != 2 {
t.Fatalf("管理端应含 draft-doc 与软删的 live-doc,got %+v", all)
}
bySlug := map[string]LibraryDocDetail{}
for _, d := range all {
bySlug[d.Slug] = d
}
if d := bySlug["draft-doc"]; d.Deleted {
t.Fatalf("draft-doc 不应标记已删除: %+v", d)
}
if d := bySlug["live-doc"]; !d.Deleted {
t.Fatalf("live-doc 应标记已删除: %+v", d)
}
}
func TestLibraryFileOrdering(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "order", Title: "排序", Published: libPub(true)})
// b 先传(id 小),a 设 sort_order 靠前
b, _ := s.AddFile(doc.ID, 1, "b.txt", strings.NewReader("b"))
a, _ := s.AddFile(doc.ID, 1, "a.txt", strings.NewReader("a"))
s.db.Model(&model.LibraryFile{}).Where("id = ?", a.ID).Update("sort_order", -1)
_ = b
d, err := s.GetPublishedBySlug("order")
if err != nil {
t.Fatalf("detail: %v", err)
}
if len(d.Files) != 2 || d.Files[0].Name != "a.txt" {
t.Fatalf("文件应按 sort_order,id 排序: %+v", d.Files)
}
}
func TestLibraryIncrDownload(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "cnt", Title: "计数", Published: libPub(true)})
f, err := s.AddFile(doc.ID, 1, "x.pdf", strings.NewReader("x"))
if err != nil {
t.Fatalf("add: %v", err)
}
s.IncrDownload(f.ID)
s.IncrDownload(f.ID)
got, err := s.GetPublicFile(f.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.DownloadCount != 2 {
t.Fatalf("下载计数应为 2,got %d", got.DownloadCount)
}
}
func TestLibraryDeleteFile(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "del", Title: "删除"})
f, err := s.AddFile(doc.ID, 1, "gone.txt", strings.NewReader("gone"))
if err != nil {
t.Fatalf("add: %v", err)
}
if err := s.DeleteFile(f.ID); err != nil {
t.Fatalf("delete file: %v", err)
}
if err := s.DeleteFile(f.ID); err == nil {
t.Fatal("重复删除应报不存在")
}
}
// ---------- 章节(两级树) ----------
func TestLibrarySectionTwoLevelConstraint(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "book", Title: "书"})
ch, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"})
if err != nil {
t.Fatalf("create chapter: %v", err)
}
sec, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "第一节"})
if err != nil {
t.Fatalf("create section: %v", err)
}
if sec.ParentID == nil || *sec.ParentID != ch.ID {
t.Fatalf("节的父应为章 %d", ch.ID)
}
// 节下不能再挂节
if _, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &sec.ID, Title: "深层"}); err == nil {
t.Fatal("三级嵌套应被拒绝")
}
// 跨条目父章节
doc2, _ := s.Create(&LibraryInput{Slug: "book2", Title: "书2"})
if _, err := s.CreateSection(doc2.ID, &SectionInput{ParentID: &ch.ID, Title: "跨条目"}); err == nil {
t.Fatal("跨条目父章节应被拒绝")
}
}
func TestLibrarySectionMoveSwap(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "mv", Title: "排序"})
a, _ := s.CreateSection(doc.ID, &SectionInput{Title: "甲"})
b, _ := s.CreateSection(doc.ID, &SectionInput{Title: "乙"})
c, _ := s.CreateSection(doc.ID, &SectionInput{Title: "丙"})
if err := s.MoveSection(b.ID, "up"); err != nil {
t.Fatalf("move up: %v", err)
}
secs, _ := s.ListSections(doc.ID)
if secs[0].ID != b.ID || secs[1].ID != a.ID || secs[2].ID != c.ID {
t.Fatalf("上移后顺序应为 乙甲丙: %+v", titles(secs))
}
// 边界 no-op
if err := s.MoveSection(b.ID, "up"); err != nil {
t.Fatalf("边界上移: %v", err)
}
if err := s.MoveSection(b.ID, "bad"); err == nil {
t.Fatal("非法方向应报错")
}
}
func titles(secs []model.LibrarySection) []string {
out := make([]string, len(secs))
for i, sec := range secs {
out[i] = sec.Title
}
return out
}
func TestLibraryImportMarkdown(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "imp", Title: "导入"})
// h2 拆分:围栏内 ## 不拆;首标题前内容并入第一章
md := "## 前言\n\n引言正文。\n\n```\n## 不是标题\n```\n\n## 第二章\n\n内容二。"
secs, err := s.ImportMarkdown(doc.ID, "how.md", md, "h2")
if err != nil {
t.Fatalf("import: %v", err)
}
if len(secs) != 2 {
t.Fatalf("应拆出 2 章,got %d: %+v", len(secs), titles(secs))
}
if secs[0].Title != "前言" || !strings.Contains(secs[0].Content, "引言正文") {
t.Fatalf("第一章错误: %+v", secs[0])
}
if !strings.Contains(secs[0].Content, "不是标题") {
t.Fatal("围栏内的 ## 行应留在第一章正文,不应被拆分")
}
if secs[1].Title != "第二章" {
t.Fatalf("第二章标题错误: %+v", secs[1])
}
// 无命中标题 → 回退单章,标题取文件名
secs2, err := s.ImportMarkdown(doc.ID, "README.md", "只有正文,没有标题。", "h1")
if err != nil {
t.Fatalf("import fallback: %v", err)
}
if len(secs2) != 1 || secs2[0].Title != "README" {
t.Fatalf("应回退单章 README: %+v", titles(secs2))
}
// 非法 splitLevel
if _, err := s.ImportMarkdown(doc.ID, "x.md", "x", "h3"); err == nil {
t.Fatal("非法拆分方式应报错")
}
}
func TestLibraryImportMarkdownBatch(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "impb", Title: "批量导入"})
files := []ImportFileInput{
{Filename: "01-不想早死.md", Content: "## 甲\n\n内容甲。"},
{Filename: "02-不要慢性自杀.md", Content: "只有正文,没有标题。"},
{Filename: "03.md", Content: "# 丙\n\n内容丙。"},
}
secs, err := s.ImportMarkdownBatch(doc.ID, files, "h2")
if err != nil {
t.Fatalf("batch import: %v", err)
}
// 文件1 按 h2 拆出 1 章;文件2、文件3 未命中 ## → 回退单章(标题取文件名)
if len(secs) != 3 {
t.Fatalf("应创建 3 章,got %d: %+v", len(secs), titles(secs))
}
if secs[0].Title != "甲" {
t.Fatalf("文件1 应按 h2 拆出章: %+v", titles(secs))
}
if secs[1].Title != "02-不要慢性自杀" {
t.Fatalf("文件2 标题应取文件名: %+v", titles(secs))
}
if secs[2].Title != "03" {
t.Fatalf("文件3 未命中 ## 应回退单章: %+v", titles(secs))
}
for i, sec := range secs {
if sec.SortOrder != i {
t.Fatalf("sort_order 应按文件顺序连续: %+v", titles(secs))
}
}
// 文件数超限
many := make([]ImportFileInput, MaxImportFiles+1)
for i := range many {
many[i] = ImportFileInput{Filename: "f.md", Content: "x"}
}
if _, err := s.ImportMarkdownBatch(doc.ID, many, "none"); err == nil {
t.Fatal("文件数超过上限应报错")
}
// 空文件列表
if _, err := s.ImportMarkdownBatch(doc.ID, nil, "none"); err == nil {
t.Fatal("空文件列表应报错")
}
}
func TestLibraryDeleteSectionCascade(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "cas", Title: "级联"})
ch, _ := s.CreateSection(doc.ID, &SectionInput{Title: "章"})
sec, _ := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "节"})
if err := s.DeleteSection(ch.ID); err != nil {
t.Fatalf("delete chapter: %v", err)
}
// 章与节都被软删
if _, err := s.section(ch.ID); err == nil {
t.Fatal("章应已删除")
}
if _, err := s.section(sec.ID); err == nil {
t.Fatal("章删除时其下小节应级联删除")
}
}
func TestLibraryDetailCarriesSections(t *testing.T) {
s, _ := newLibraryTestService(t)
doc, _ := s.Create(&LibraryInput{Slug: "carry", Title: "携带", Published: libPub(true)})
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "章一", Content: "正文一"}); err != nil {
t.Fatalf("create: %v", err)
}
d, err := s.GetPublishedBySlug("carry")
if err != nil {
t.Fatalf("detail: %v", err)
}
if len(d.Sections) != 1 || d.Sections[0].Content != "正文一" {
t.Fatalf("公开详情应携带章节: %+v", d.Sections)
}
// 草稿条目不可见
doc2, _ := s.Create(&LibraryInput{Slug: "draft", Title: "草稿书"})
if _, err := s.CreateSection(doc2.ID, &SectionInput{Title: "草稿章"}); err != nil {
t.Fatalf("create: %v", err)
}
if _, err := s.GetPublishedBySlug("draft"); err == nil {
t.Fatal("草稿条目不应出现在公开详情")
}
}

View File

@@ -163,8 +163,10 @@ func (s *AuthService) LookupFamilyByRefreshPlain(plain string, userID uint) uint
return sessionFamilyID(rt)
}
// TouchDeviceFromRefresh 用当前请求的 IP/UA 校准本会话,并回填尚未写入的 family_id。
func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint) uint {
// TouchDeviceFromRefresh 刷新本会话活跃时间,并回填尚未写入的 family_id。
// 会话的 IP/UA 以登录时记录为准,不随访问端环境漂移,
// 否则设备指纹会跟着请求变,设备列表在 UA 模拟/代理下会来回跳。
func (s *AuthService) TouchDeviceFromRefresh(plain string, userID uint) uint {
if plain == "" {
return 0
}
@@ -177,12 +179,6 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
}
now := time.Now()
upd := map[string]any{"last_used_at": now, "updated_at": now}
if v := truncateStr(ip, 45); v != "" {
upd["ip"] = v
}
if v := truncateStr(ua, 500); v != "" {
upd["user_agent"] = v
}
fid := sessionFamilyID(rt)
if rt.FamilyID == 0 {
upd["family_id"] = fid
@@ -191,26 +187,29 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
return fid
}
// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部
// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。
func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int {
// maxActiveSessions 每个用户允许并存的活跃会话(登录设备)上限,
// 与设备列表展示上限 loginDeviceLimit 一致;超出时淘汰最久未使用的会话。
const maxActiveSessions = 20
// enforceSessionCap 会话上限守护:多会话并存下新登录不踢旧设备,仅当该用户
// 活跃会话超过 maxActiveSessions 时,按 last_used_at 淘汰最旧的会话腾位;
// 返回淘汰行数供上层广播告知(新会话 last_used_at 最新,不会被淘汰)。
func (s *AuthService) enforceSessionCap(userID uint) int {
now := time.Now()
var actives []model.RefreshToken
if err := s.db.Select("id", "family_id").
if err := s.db.Select("id").
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
Order("last_used_at DESC, created_at DESC, id DESC").
Find(&actives).Error; err != nil {
return 0
}
ids := make([]uint, 0)
for _, t := range actives {
if sessionFamilyID(t) == keepFamily {
continue
}
ids = append(ids, t.ID)
}
if len(ids) == 0 {
if len(actives) <= maxActiveSessions {
return 0
}
ids := make([]uint, 0, len(actives)-maxActiveSessions)
for _, t := range actives[maxActiveSessions:] {
ids = append(ids, t.ID)
}
res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
if res.Error != nil {

View File

@@ -0,0 +1,358 @@
package service
import (
"errors"
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func newLoginSessionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.RefreshToken{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func newLoginSessionUser(t *testing.T, db *gorm.DB, username string) *model.User {
t.Helper()
hashed, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := model.User{Username: username, Password: string(hashed)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
return &user
}
func countActiveSessions(t *testing.T, db *gorm.DB, userID uint) int64 {
t.Helper()
var n int64
if err := db.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Count(&n).Error; err != nil {
t.Fatalf("count active sessions: %v", err)
}
return n
}
// TestLoginMultiSessionCoexist 多会话并存:不同设备先后登录互不顶号,
// 各会话的 access token(family_id)均持续有效。
func TestLoginMultiSessionCoexist(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "alice")
uas := []string{"Chrome-Windows", "Edge-Windows", "iPhone-Safari"}
families := make([]uint, 0, len(uas))
for i, ua := range uas {
access, refresh, _, kicked, err := s.Login("alice", "password123", "1.2.3.4", ua)
if err != nil {
t.Fatalf("login #%d: %v", i+1, err)
}
if kicked != 0 {
t.Fatalf("login #%d 不应踢掉旧会话,kicked=%d", i+1, kicked)
}
if _, err := s.ValidateRefreshToken(refresh); err != nil {
t.Fatalf("login #%d refresh 校验失败: %v", i+1, err)
}
claims, err := s.ParseToken(access)
if err != nil {
t.Fatalf("login #%d 解析 access token: %v", i+1, err)
}
if _, err := s.ValidateClaims(claims); err != nil {
t.Fatalf("login #%d 会话应有效: %v", i+1, err)
}
families = append(families, claims.FamilyID)
}
if n := countActiveSessions(t, db, user.ID); n != int64(len(uas)) {
t.Fatalf("应共存 %d 个活跃会话,实际 %d", len(uas), n)
}
for _, fam := range families {
if !s.familySessionActive(user.ID, fam) {
t.Fatalf("family=%d 会话应仍有效", fam)
}
}
}
// TestSessionCapEvictsOldest 会话上限守护:活跃会话超过 maxActiveSessions 时,
// 按 last_used_at 淘汰最旧会话,其余会话不受影响。
func TestSessionCapEvictsOldest(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "bob")
families := make([]uint, 0, maxActiveSessions)
for i := 0; i < maxActiveSessions; i++ {
_, family, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device")
if err != nil {
t.Fatalf("create #%d: %v", i+1, err)
}
if kicked != 0 {
t.Fatalf("create #%d 不应淘汰会话,kicked=%d", i+1, kicked)
}
families = append(families, family)
}
// 人为拉开 last_used_at,保证淘汰顺序确定(第 1 个最旧)
base := time.Now().Add(-2 * time.Hour)
for i, fam := range families {
if err := db.Model(&model.RefreshToken{}).Where("family_id = ?", fam).
Update("last_used_at", base.Add(time.Duration(i)*time.Minute)).Error; err != nil {
t.Fatalf("调整 last_used_at: %v", err)
}
}
_, _, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device-new")
if err != nil {
t.Fatalf("create #%d: %v", maxActiveSessions+1, err)
}
if kicked != 1 {
t.Fatalf("超出上限应淘汰 1 个最旧会话,kicked=%d", kicked)
}
if n := countActiveSessions(t, db, user.ID); n != maxActiveSessions {
t.Fatalf("淘汰后应剩 %d 个活跃会话,实际 %d", maxActiveSessions, n)
}
if s.familySessionActive(user.ID, families[0]) {
t.Fatal("最旧会话应已被淘汰")
}
for _, fam := range families[1:] {
if !s.familySessionActive(user.ID, fam) {
t.Fatalf("family=%d 会话不应被淘汰", fam)
}
}
}
// TestRevokeLoginDeviceWithMultiSessions 多会话下剔除设备:仅目标会话失效,
// 当前会话不受影响(沿用既有剔除语义的回归保障)。
func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "carol")
access1, refresh1, _, _, err := s.Login("carol", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login 1: %v", err)
}
claims1, err := s.ParseToken(access1)
if err != nil {
t.Fatalf("parse access 1: %v", err)
}
family1 := claims1.FamilyID
access2, _, _, _, err := s.Login("carol", "password123", "2.2.2.2", "iPhone-Mobile")
if err != nil {
t.Fatalf("login 2: %v", err)
}
claims2, err := s.ParseToken(access2)
if err != nil {
t.Fatalf("parse access 2: %v", err)
}
family2 := claims2.FamilyID
devices, err := s.ListLoginDevices(user.ID, family1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("list devices: %v", err)
}
if len(devices) != 2 {
t.Fatalf("应列出 2 台设备,实际 %d", len(devices))
}
var targetID uint
for _, d := range devices {
if !d.Current {
targetID = d.ID
}
}
if targetID == 0 {
t.Fatal("未找到非当前设备")
}
if err := s.RevokeLoginDevice(user.ID, family1, targetID); err != nil {
t.Fatalf("revoke device: %v", err)
}
if s.familySessionActive(user.ID, family2) {
t.Fatal("被剔除设备的会话应已失效")
}
if _, err := s.ValidateRefreshToken(refresh1); err != nil {
t.Fatalf("当前设备会话不应受影响: %v", err)
}
}
// TestRefreshReplayBeyondGraceSelfHeals 宽限期外的同链重放:最常见成因是
// 轮换响应丢失(RSC 预取被中止、移动端切后台挂起等),而非盗用。重放应补发
// 轮转链头节点 token 对自愈,家族保持活跃,也不牵连同用户其他登录设备。
func TestRefreshReplayBeyondGraceSelfHeals(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "dave")
access1, refresh1, _, _, err := s.Login("dave", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login 1: %v", err)
}
claims1, err := s.ParseToken(access1)
if err != nil {
t.Fatalf("parse access 1: %v", err)
}
access2, _, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
if err != nil {
t.Fatalf("login 2: %v", err)
}
claims2, err := s.ParseToken(access2)
if err != nil {
t.Fatalf("parse access 2: %v", err)
}
// 家族1 正常轮转一次:旧行吊销并链接到新行,得到头节点 refresh2
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate 1: %v", err)
}
// 把旧行 rotated_at 拨回宽限期之外,模拟「轮换响应丢失后的延迟重放」
if err := db.Model(&model.RefreshToken{}).
Where("token_hash = ?", hashRefreshToken(refresh1)).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
// 宽限期外重放:自愈放行,返回头节点(当前代)token 对
at, replayRefresh, replayUser, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("宽限期外重放应自愈放行,实际 %v", err)
}
if replayRefresh != refresh2 {
t.Fatal("自愈应返回轮转链头节点的 refresh token")
}
if replayUser == nil || replayUser.ID != user.ID {
t.Fatal("自愈应返回所属用户")
}
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
t.Fatalf("头节点 refresh 应保持有效: %v", err)
}
if !s.familySessionActive(user.ID, claims1.FamilyID) {
t.Fatal("重放所在家族应保持活跃,不被吊销")
}
if !s.familySessionActive(user.ID, claims2.FamilyID) {
t.Fatal("其他登录设备(家族)不应被牵连")
}
if n := countActiveSessions(t, db, user.ID); n != 2 {
t.Fatalf("两个家族应共 2 个活跃会话,实际 %d", n)
}
if claims, err := s.ParseToken(at); err != nil {
t.Fatalf("自愈签发的 access token 应可解析: %v", err)
} else if _, err := s.ValidateClaims(claims); err != nil {
t.Fatalf("自愈签发的 access token 应有效: %v", err)
}
}
// TestRefreshReplayMultiHopHealsToHead 轮转链已前进多代后重放最早一代:
// 应沿 rotated_to 链走到头节点,补发最新一代 token 对。
func TestRefreshReplayMultiHopHealsToHead(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
newLoginSessionUser(t, db, "frank")
_, refresh1, _, _, err := s.Login("frank", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login: %v", err)
}
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate 1: %v", err)
}
_, refresh3, _, err := s.RotateRefreshToken(refresh2, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate 2: %v", err)
}
// 各代 rotated_at 均拨回宽限期之外,模拟浏览器长时间后才补投旧 token
if err := db.Model(&model.RefreshToken{}).
Where("token_hash IN ?", []string{hashRefreshToken(refresh1), hashRefreshToken(refresh2)}).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("多级链重放应自愈到头节点,实际 %v", err)
}
if replayRefresh != refresh3 {
t.Fatal("自愈应返回最新一代(头节点)的 refresh token")
}
}
// TestRefreshReplayDeadChainRejected 轮转链已无有效头节点(家族被整体吊销:
// 设备剔除/超限淘汰/改密/封禁)时,宽限期外重放属真实失效,应拒绝并返回
// ErrRefreshInvalid,且不得复活任何会话。
func TestRefreshReplayDeadChainRejected(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "grace")
_, refresh1, _, _, err := s.Login("grace", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login: %v", err)
}
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
t.Fatalf("rotate: %v", err)
}
// 模拟改密/封禁/剔除设备类处置:家族整体吊销
if err := s.RevokeAllUserRefreshTokens(user.ID); err != nil {
t.Fatalf("revoke all: %v", err)
}
if err := db.Model(&model.RefreshToken{}).
Where("token_hash = ?", hashRefreshToken(refresh1)).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshInvalid) {
t.Fatalf("死链重放应返回 ErrRefreshInvalid,实际 %v", err)
}
if n := countActiveSessions(t, db, user.ID); n != 0 {
t.Fatalf("死链自愈不得复活会话,活跃会话应仍为 0,实际 %d", n)
}
}
// TestRefreshReuseDevGraceReturnsSamePair dev 模式宽限期放宽:轮换后远超
// 生产宽限窗的同链重放(dev 重启丢响应的常态)仍返回同一对,不升级为盗用判定。
func TestRefreshReuseDevGraceReturnsSamePair(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret").WithDevMode(true)
newLoginSessionUser(t, db, "eve")
_, refresh1, _, _, err := s.Login("eve", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login: %v", err)
}
_, newRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate: %v", err)
}
// rotated_at 拨回生产宽限期之外(仍在 dev 宽限内)
if err := db.Model(&model.RefreshToken{}).
Where("token_hash = ?", hashRefreshToken(refresh1)).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("dev 宽限期内重放不应报错: %v", err)
}
if replayRefresh != newRefresh {
t.Fatal("dev 宽限期内重放应返回同一对新 token")
}
}

View File

@@ -0,0 +1,449 @@
package service
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
)
// 媒体库(管理后台):盘点站点内全部图片资源。
// 数据源 = uploads 五个分类目录的磁盘扫描 + attachments 表元数据合并:
// - 旧站导入图 / 背景图 / 广告素材 / 品牌 Logo 落盘但无附件记录,只有磁盘扫描能覆盖
// - 附件记录(头像、帖子插图)补充上传者、创建时间等元数据;远程存储对象(/api/media/)仅存在于库中
// 同名(去扩展名)同时存在原图与 WebP 时只展示 WebP,原图 URL 挂在 OriginalURL 供查看。
// 媒体库分类:目录名即分类键,前端 tab 直接使用
var mediaLibraryCategories = []struct {
Key string // 分类键(image/avatar/background/ads/brand)
Dir string // uploads 下的子目录
Name string // 展示名
}{
{"image", "images", "帖子插图"},
{"avatar", "avatars", "用户头像"},
{"background", "backgrounds", "站点背景"},
{"ads", "ads", "广告素材"},
{"brand", "brand", "品牌资源"},
}
// 衍生分类:不对应 uploads 子目录,按附件 source 归类
const (
mediaCategoryBook = "book"
mediaCategoryBookName = "书籍"
)
// mediaCategoryForAttachment 附件在媒体库中的展示分类:
// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”,
// 其余按 kind 对应磁盘目录分类。
func mediaCategoryForAttachment(kind, source string) (key, name string) {
if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent {
return mediaCategoryBook, mediaCategoryBookName
}
for _, cat := range mediaLibraryCategories {
if cat.Key == kind {
return cat.Key, cat.Name
}
}
return kind, kind
}
// MediaLibraryItem 媒体库条目
type MediaLibraryItem struct {
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
OriginalURL string `json:"original_url,omitempty"` // 同名原图(仅当原图与 WebP 并存)
Category string `json:"category"` // image/avatar/background/ads/brand
CategoryName string `json:"category_name"`
Name string `json:"name"` // 文件名(含扩展名)
MIME string `json:"mime"`
Size int64 `json:"size"`
Width int `json:"width"`
Height int `json:"height"`
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
}
// AdminMediaLibrary 全站图片盘点:磁盘扫描 + 附件元数据合并,按时间倒序。
// 返回条目列表与各分类计数(含全部计数由前端按列表累加)。
func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, error) {
// 附件元数据索引:URL → 记录(联出上传者昵称)
type attMeta struct {
Kind string
Source string
URL string
MIME string
Size int
Width int
Height int
Nickname string
Username string
CreatedAt time.Time
}
var rows []attMeta
if err := s.db.Table("attachments").
Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
Joins("LEFT JOIN users ON users.id = attachments.user_id").
Scan(&rows).Error; err != nil {
return nil, nil, err
}
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
// 上传者/时间取最早一条,计数即该文件的引用次数
type urlMeta struct {
meta attMeta
count int
}
agg := make(map[string]*urlMeta, len(rows))
for _, r := range rows {
if a, ok := agg[r.URL]; ok {
a.count++
if r.CreatedAt.Before(a.meta.CreatedAt) {
a.meta = r
}
} else {
agg[r.URL] = &urlMeta{meta: r, count: 1}
}
}
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
items := make([]MediaLibraryItem, 0, len(agg))
for url, a := range agg {
r := a.meta
if !strings.HasPrefix(url, "/api/media/") {
continue // 本地文件在磁盘扫描时按 URL 合并元数据
}
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
uploader := r.Nickname
if uploader == "" {
uploader = r.Username
}
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: url, Category: catKey, CategoryName: catName,
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
})
}
// 磁盘扫描五类目录;同名(去扩展名)成组,WebP 优先展示、原图挂 OriginalURL
type group struct {
webp *MediaLibraryItem
original *MediaLibraryItem
}
for _, cat := range mediaLibraryCategories {
dir := filepath.Join(s.dir, cat.Dir)
entries, err := os.ReadDir(dir)
if err != nil {
continue // 目录不存在视为空
}
groups := map[string]*group{}
for _, e := range entries {
if e.IsDir() || strings.HasSuffix(e.Name(), ".partial") {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
continue
}
url := "/uploads/" + cat.Dir + "/" + e.Name()
item := MediaLibraryItem{
URL: url, Category: cat.Key, CategoryName: cat.Name,
Name: e.Name(),
MIME: map[string]string{
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif",
}[ext],
}
if info, err := e.Info(); err == nil {
item.Size = info.Size()
mt := info.ModTime()
item.UploadedAt = &mt
}
if a, ok := agg[url]; ok {
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
m := a.meta
uploader := m.Nickname
if uploader == "" {
uploader = m.Username
}
ca := m.CreatedAt
item.Uploader = uploader
item.UploadedAt = &ca
item.RefCount = a.count
if m.Size > 0 {
item.Size = int64(m.Size)
}
if m.Width > 0 {
item.Width, item.Height = m.Width, m.Height
}
// 书库封面 / 正文插图改归“书籍”分类
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
item.Category = mediaCategoryBook
item.CategoryName = mediaCategoryBookName
}
}
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
if f, err := os.Open(filepath.Join(dir, e.Name())); err == nil {
w, h, derr := decodeImageSizeReader(f, item.MIME)
_ = f.Close()
if derr == nil {
item.Width, item.Height = w, h
}
}
}
stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
g := groups[stem]
if g == nil {
g = &group{}
groups[stem] = g
}
if ext == ".webp" {
g.webp = &item
} else if g.original == nil {
g.original = &item
}
}
for _, g := range groups {
switch {
case g.webp != nil && g.original != nil:
// 原图与 WebP 并存:只展示 WebP,原图作 original_url
g.webp.OriginalURL = g.original.URL
if g.webp.Size == 0 {
g.webp.Size = g.original.Size
}
items = append(items, *g.webp)
case g.webp != nil:
items = append(items, *g.webp)
default:
items = append(items, *g.original)
}
}
}
sort.Slice(items, func(i, j int) bool {
ti, tj := items[i].UploadedAt, items[j].UploadedAt
switch {
case ti == nil && tj == nil:
return items[i].URL < items[j].URL
case ti == nil:
return false
case tj == nil:
return true
}
return ti.After(*tj)
})
counts := map[string]int{}
for _, it := range items {
counts[it.Category]++
}
s.attachMediaSources(items)
return items, counts, nil
}
// attachMediaSources 为条目补来源回链:
// - 头像:users.avatar 精确匹配 → 用户主页
// - 其余:分块 LIKE 扫描帖子/评论正文(展示 URL 与同名原图都参与匹配),帖子优先于评论
//
// 内容 LIKE 无索引,纯顺序扫描,但仅管理端低频调用;分块 OR 控制往返次数。
func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
urlSet := map[string]bool{}
for _, it := range items {
for _, u := range []string{it.URL, it.OriginalURL} {
if strings.HasPrefix(u, "/uploads/") {
urlSet[u] = true
}
}
}
if len(urlSet) == 0 {
return
}
urls := make([]string, 0, len(urlSet))
for u := range urlSet {
urls = append(urls, u)
}
type postRef struct {
id uint
title string
}
postByURL := map[string]postRef{}
type commentRef struct {
id, postID uint
floor uint
isRoot bool
}
commentByURL := map[string]commentRef{}
for i := 0; i < len(urls); i += 40 {
end := i + 40
if end > len(urls) {
end = len(urls)
}
chunk := urls[i:end]
conds := make([]string, len(chunk))
args := make([]any, len(chunk))
for j, u := range chunk {
conds[j] = "content LIKE ?"
args[j] = "%" + u + "%"
}
where := strings.Join(conds, " OR ")
var prows []struct {
ID uint
Title string
Content string
}
if err := s.db.Model(&model.Post{}).Select("id, title, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&prows).Error; err != nil {
return
}
for _, p := range prows {
for _, u := range chunk {
if strings.Contains(p.Content, u) {
if _, ok := postByURL[u]; !ok {
postByURL[u] = postRef{id: p.ID, title: p.Title}
}
}
}
}
var crows []struct {
ID uint
PostID uint
Content string
}
if err := s.db.Model(&model.Comment{}).Select("id, post_id, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&crows).Error; err != nil {
return
}
for _, cm := range crows {
for _, u := range chunk {
if strings.Contains(cm.Content, u) {
if _, ok := commentByURL[u]; !ok {
commentByURL[u] = commentRef{id: cm.ID, postID: cm.PostID}
}
}
}
}
}
// 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id})
commentIDs := make([]uint, 0, len(commentByURL))
for _, cm := range commentByURL {
commentIDs = append(commentIDs, cm.id)
}
commentAnchors := CommentAnchors(s.db, commentIDs)
for u, cm := range commentByURL {
if a, ok := commentAnchors[cm.id]; ok {
cm.floor, cm.isRoot = a.Floor, a.IsRoot
commentByURL[u] = cm
}
}
// 评论来源要带宿主帖子标题,一次性补齐
postTitle := func(id uint) string {
var p model.Post
if err := s.db.Select("id, title").First(&p, id).Error; err != nil {
return ""
}
return p.Title
}
commentPostTitle := map[uint]string{}
for _, cm := range commentByURL {
if _, ok := commentPostTitle[cm.postID]; !ok {
commentPostTitle[cm.postID] = postTitle(cm.postID)
}
}
// 头像使用者:users.avatar 精确匹配(同一头像多人使用时取最早的用户)
userByAvatar := map[string]struct {
ID uint
Nickname, Username string
}{}
var urows []struct {
ID uint
Nickname string
Username string
Avatar string
}
if err := s.db.Model(&model.User{}).Select("id, nickname, username, avatar").
Where("avatar IN ?", urls).Order("id ASC").Find(&urows).Error; err == nil {
for _, u := range urows {
if _, ok := userByAvatar[u.Avatar]; !ok {
userByAvatar[u.Avatar] = struct {
ID uint
Nickname, Username string
}{ID: u.ID, Nickname: u.Nickname, Username: u.Username}
}
}
}
truncate := func(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
postLabel := func(p postRef) string {
title := p.title
if title == "" {
title = fmt.Sprintf("#%d", p.id)
}
return "帖子《" + truncate(title, 40) + "》"
}
for i := range items {
it := &items[i]
// 头像使用者优先(头像正文引用场景极少)
if u, ok := userByAvatar[it.URL]; ok {
name := u.Nickname
if name == "" {
name = u.Username
}
it.SourceURL = fmt.Sprintf("/u/%d", u.ID)
it.SourceLabel = "@" + name + " 的头像"
continue
}
for _, u := range []string{it.URL, it.OriginalURL} {
if u == "" || it.SourceURL != "" {
continue
}
if p, ok := postByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d", p.id)
it.SourceLabel = postLabel(p)
break
}
if cm, ok := commentByURL[u]; ok {
if cm.floor > 0 {
if cm.isRoot {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor)
} else {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id)
}
} else {
it.SourceURL = fmt.Sprintf("/post/%d", cm.postID)
}
title := commentPostTitle[cm.postID]
if title != "" {
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
} else {
it.SourceLabel = "帖子评论"
}
break
}
}
}
}

View File

@@ -0,0 +1,444 @@
package service
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"time"
xwebp "golang.org/x/image/webp"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newMediaLibraryService(t *testing.T) (*UploadService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func writeMediaFile(t *testing.T, dir, rel string, data []byte) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, data, 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
func tinyPNG() []byte {
src := image.NewRGBA(image.Rect(0, 0, 12, 8))
src.Set(0, 0, color.RGBA{1, 2, 3, 255})
var buf bytes.Buffer
_ = png.Encode(&buf, src)
return buf.Bytes()
}
func TestAdminMediaLibraryWebpPreferredWithOriginalURL(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/pair.jpg", []byte("fake-jpeg-bytes"))
writeMediaFile(t, dir, "images/pair.webp", []byte("fake-webp-bytes"))
writeMediaFile(t, dir, "images/solo.png", tinyPNG())
writeMediaFile(t, dir, "images/trash.txt", []byte("not an image"))
writeMediaFile(t, dir, "images/partial.jpg.partial", []byte("temp"))
writeMediaFile(t, dir, "ads/qr.gif", []byte("GIF89a-fake"))
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 同名原图 + WebP 并存:只出 WebP 条目,原图挂 original_url
pair, ok := byURL["/uploads/images/pair.webp"]
if !ok {
t.Fatalf("pair.webp missing; items=%v", items)
}
if pair.OriginalURL != "/uploads/images/pair.jpg" {
t.Fatalf("want original pair.jpg, got %q", pair.OriginalURL)
}
if _, exists := byURL["/uploads/images/pair.jpg"]; exists {
t.Fatal("pair.jpg should be hidden behind webp")
}
// 无配对的单文件:保留原格式并解析出宽高
solo, ok := byURL["/uploads/images/solo.png"]
if !ok {
t.Fatal("solo.png missing")
}
if solo.MIME != "image/png" || solo.Width != 12 || solo.Height != 8 {
t.Fatalf("solo.png meta wrong: %+v", solo)
}
// 非图片与 .partial 被过滤
if _, exists := byURL["/uploads/images/trash.txt"]; exists {
t.Fatal("trash.txt should be excluded")
}
if _, exists := byURL["/uploads/images/partial.jpg.partial"]; exists {
t.Fatal(".partial should be excluded")
}
// 广告素材目录计入 ads 分类
if qr, ok := byURL["/uploads/ads/qr.gif"]; !ok || qr.Category != "ads" {
t.Fatalf("ads/qr.gif missing or wrong category: %+v", qr)
}
if counts["image"] != 2 || counts["ads"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestAdminMediaLibraryMergesAttachmentMetaAndRemoteObjects(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 附件记录引用的磁盘文件必须真实存在,否则该记录会被跳过(见 SkipsLocalMissingFiles)
writeMediaFile(t, dir, "images/abc.webp", []byte("fake-webp-bytes"))
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage,
URL: "/uploads/images/abc.webp", MIME: "image/webp",
Size: 4567, Width: 320, Height: 240, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindAvatar,
URL: "/api/media/remote01", MIME: "image/webp",
Size: 999, Width: 128, Height: 128, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create remote attachment: %v", err)
}
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 磁盘文件与附件记录合并:元数据以记录为准(上传者/尺寸/创建时间)
disk, ok := byURL["/uploads/images/abc.webp"]
if !ok {
t.Fatalf("attachment-backed local file missing: %+v", items)
}
if disk.Uploader != "爱丽丝" || disk.Size != 4567 || disk.Width != 320 || disk.Height != 240 {
t.Fatalf("meta not merged: %+v", disk)
}
// 远程存储对象:不在磁盘上也必须出现在媒体库
remote, ok := byURL["/api/media/remote01"]
if !ok {
t.Fatal("remote object missing")
}
if remote.Category != "avatar" || remote.Uploader != "爱丽丝" {
t.Fatalf("remote meta wrong: %+v", remote)
}
if counts["avatar"] != 1 {
t.Fatalf("avatar count wrong: %v", counts)
}
}
func TestAdminMediaLibrarySkipsLocalMissingFiles(t *testing.T) {
s, _ := newMediaLibraryService(t)
if err := s.db.Create(&model.Attachment{
UserID: 1, Kind: model.AttachmentKindImage,
URL: "/uploads/images/ghost.webp", MIME: "image/webp", Size: 1,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
for _, it := range items {
if it.URL == "/uploads/images/ghost.webp" {
t.Fatal("local-missing file should not be listed")
}
}
}
func TestAdminMediaLibraryAttachSources(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "avatars/av1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/p1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/c1.jpg", []byte("fake-jpeg"))
// 头像被 bob 使用
if err := s.db.Create(&model.User{
Username: "bob", Nickname: "阿Bob", Avatar: "/uploads/avatars/av1.webp",
}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
// 帖子正文引用 p1
post := model.Post{UserID: 1, Title: "图片来源测试", Content: "看图 ![a](/uploads/images/p1.webp)"}
if err := s.db.Create(&post).Error; err != nil {
t.Fatalf("create post: %v", err)
}
// 评论引用 c1
if err := s.db.Create(&model.Comment{
PostID: post.ID, UserID: 1, Content: "评论里也有 /uploads/images/c1.jpg",
}).Error; err != nil {
t.Fatalf("create comment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
av, ok := byURL["/uploads/avatars/av1.webp"]
if !ok {
t.Fatal("avatar item missing")
}
if av.SourceURL != "/u/1" || av.SourceLabel != "@阿Bob 的头像" {
t.Fatalf("avatar source wrong: %+v", av)
}
pimg, ok := byURL["/uploads/images/p1.webp"]
if !ok {
t.Fatal("post image missing")
}
if pimg.SourceURL != fmt.Sprintf("/post/%d", post.ID) ||
!strings.Contains(pimg.SourceLabel, "图片来源测试") {
t.Fatalf("post source wrong: %+v", pimg)
}
cimg, ok := byURL["/uploads/images/c1.jpg"]
if !ok {
t.Fatal("comment image missing")
}
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
t.Fatalf("comment source url wrong: %+v", cimg)
}
if !strings.Contains(cimg.SourceLabel, "下的评论") {
t.Fatalf("comment source label wrong: %+v", cimg)
}
}
func TestAdminMediaLibraryBookCategory(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
mkAtt := func(source, url string) {
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment %s: %v", source, err)
}
}
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 封面(磁盘)与正文插图(远程)都归“书籍”
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
it, ok := byURL[u]
if !ok {
t.Fatalf("%s missing", u)
}
if it.Category != "book" || it.CategoryName != "书籍" {
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
}
}
// 普通帖子插图仍归 image
if post := byURL["/api/media/post-image"]; post.Category != "image" {
t.Fatalf("post image should stay image category, got %q", post.Category)
}
if counts["book"] != 2 || counts["image"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestAdminMediaLibraryDedupAggregation(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/dup.webp", []byte("fake-webp-bytes"))
mkUser := func(username, nickname string) model.User {
if err := s.db.Create(&model.User{Username: username, Nickname: nickname}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var u model.User
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
t.Fatalf("load user: %v", err)
}
return u
}
alice := mkUser("alice", "爱丽丝")
bob := mkUser("bob", "阿Bob")
early := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
late := early.Add(2 * time.Hour)
// 同一张图被两人先后上传(内容寻址去重后共享同一文件/对象)
mkAtt := func(u model.User, url string, at time.Time) {
if err := s.db.Create(&model.Attachment{
UserID: u.ID, Kind: model.AttachmentKindImage,
URL: url, MIME: "image/webp",
Size: 100, Width: 10, Height: 10, CreatedAt: at,
}).Error; err != nil {
t.Fatalf("create attachment %s: %v", url, err)
}
}
mkAtt(alice, "/uploads/images/dup.webp", early)
mkAtt(bob, "/uploads/images/dup.webp", late)
mkAtt(bob, "/api/media/rdup", early)
mkAtt(alice, "/api/media/rdup", late)
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
countByURL := map[string]int{}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
countByURL[it.URL]++
byURL[it.URL] = it
}
// 本地去重文件:单个条目,上传者/时间取最早上传的爱丽丝,引用计数 2
local, ok := byURL["/uploads/images/dup.webp"]
if !ok {
t.Fatal("dedup local file missing")
}
if countByURL["/uploads/images/dup.webp"] != 1 {
t.Fatal("dedup local file should appear exactly once")
}
if local.Uploader != "爱丽丝" || local.RefCount != 2 {
t.Fatalf("local dedup meta wrong: %+v", local)
}
if local.UploadedAt == nil || !local.UploadedAt.Equal(early) {
t.Fatalf("local dedup time should be earliest upload, got %v", local.UploadedAt)
}
// 远程对象同 URL 两条记录:也只出一个条目,取最早的 bob
remote, ok := byURL["/api/media/rdup"]
if !ok {
t.Fatal("dedup remote object missing")
}
if countByURL["/api/media/rdup"] != 1 {
t.Fatal("dedup remote object should appear exactly once")
}
if remote.Uploader != "阿Bob" || remote.RefCount != 2 {
t.Fatalf("remote dedup meta wrong: %+v", remote)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 960×640 PNG → 缩到 480×320 的 WebP
big := image.NewRGBA(image.Rect(0, 0, 960, 640))
var bigBuf bytes.Buffer
if err := png.Encode(&bigBuf, big); err != nil {
t.Fatalf("encode png: %v", err)
}
writeMediaFile(t, dir, "images/big.png", bigBuf.Bytes())
// 小图(≤480)直接回原图
writeMediaFile(t, dir, "images/small.png", tinyPNG())
bigThumb, err := s.MediaThumb("/uploads/images/big.png")
if err != nil {
t.Fatalf("MediaThumb big: %v", err)
}
img, err := xwebp.Decode(bytes.NewReader(bigThumb))
if err != nil {
t.Fatalf("thumb not webp: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 320 {
t.Fatalf("thumb size wrong: %v", img.Bounds())
}
small, err := s.MediaThumb("/uploads/images/small.png")
if err != nil {
t.Fatalf("MediaThumb small: %v", err)
}
if !bytes.Equal(small, tinyPNG()) {
t.Fatal("small image should be returned as-is")
}
// 缓存命中:第二次调用结果一致
again, err := s.MediaThumb("/uploads/images/big.png")
if err != nil || !bytes.Equal(again, bigThumb) {
t.Fatalf("cache miss/mismatch: %v", err)
}
// 非法与越界路径
if _, err := s.MediaThumb("/api/media/xxx"); err == nil {
t.Fatal("remote url should be rejected")
}
if _, err := s.MediaThumb("/uploads/../secrets/x.png"); err == nil {
t.Fatal("traversal should be rejected")
}
if _, err := s.MediaThumb("/uploads/other/x.png"); err == nil {
t.Fatal("unknown category should be rejected")
}
}

View File

@@ -0,0 +1,93 @@
package service
import (
"crypto/sha1"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
)
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
const MediaThumbMaxSide = 480
// MediaThumb 返回指定 /uploads/ 图片的缩略图字节。
func (s *UploadService) MediaThumb(url string) ([]byte, error) {
if !strings.HasPrefix(url, "/uploads/") || len(url) > 512 {
return nil, errors.New("无效地址")
}
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
return nil, errors.New("无效地址")
}
dir := filepath.Dir(rel)
ok := false
for _, cat := range mediaLibraryCategories {
if cat.Dir == dir {
ok = true
break
}
}
if !ok {
return nil, errors.New("无效地址")
}
ext := strings.ToLower(filepath.Ext(rel))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
return nil, errors.New("非图片")
}
src := filepath.Join(s.dir, rel)
info, err := os.Stat(src)
if err != nil || info.IsDir() {
return nil, errors.New("文件不存在")
}
// 磁盘缓存:哈希命名防跨分类文件名冲突;mtime 旧于源文件则重建
sum := sha1.Sum([]byte(url))
thumbPath := filepath.Join(s.dir, ".thumbs", hex.EncodeToString(sum[:])+".webp")
if ti, err := os.Stat(thumbPath); err == nil && !ti.IsDir() && !ti.ModTime().Before(info.ModTime()) {
return os.ReadFile(thumbPath)
}
data, err := os.ReadFile(src)
if err != nil {
return nil, err
}
img, err := decodeImageBytes(data, ext == ".webp")
if err != nil {
return nil, err // 如动图 WebP,调用方回退原图
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= MediaThumbMaxSide && h <= MediaThumbMaxSide {
// 小图无需缩放,直接回原图省 CPU
return data, nil
}
nw, nh := w, h
if w >= h {
nw = MediaThumbMaxSide
nh = max(1, h*MediaThumbMaxSide/w)
} else {
nh = MediaThumbMaxSide
nw = max(1, w*MediaThumbMaxSide/h)
}
dst := scaleImage(img, nw, nh)
out, err := encodeLossyWebP(dst, VariantWebPQuality)
if err != nil {
return nil, err
}
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
_ = atomicWriteFile(thumbPath, out)
return out, nil
}

View File

@@ -24,6 +24,9 @@ import (
type mailPayload struct{ To, Subject, Body string }
// ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示
var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录")
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
password, e := o.open(c.Password, "mail:password")
if e != nil {
@@ -392,6 +395,16 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
return 0, nil
}
}
if purpose == "register" {
// 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示
var n int64
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
return 0, e
}
if n > 0 {
return 0, ErrEmailRegistered
}
}
code := randomCode()
hash := counterKey(email + ":" + purpose + ":" + code)
e = o.db.Transaction(func(tx *gorm.DB) error {
@@ -402,6 +415,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
})
return 0, e
}
const (
codeVerifyMaxAttempts = 5
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致

View File

@@ -16,6 +16,8 @@ import (
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
)
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
@@ -137,6 +139,65 @@ func (o *Operations) StoreFile(path, mimeType string, public bool) (string, erro
}
return id, nil
}
// blobObjectID 内容寻址对象的稳定 ID:公开/私有加不同前缀,同字节也不共享可见性。
func blobObjectID(hash string, public bool) string {
if public {
return "pub_" + hash
}
return "prv_" + hash
}
// StoreFileHashed 按内容 hash 幂等上传:同一 (hash, public) 只存一份,重复上传跳过传输。
// 本地后端返回空 ID(文件留在本地 blob 目录,由调用方做引用计数)。
func (o *Operations) StoreFileHashed(path, mimeType string, public bool, hash string) (string, error) {
v, version, e := o.read(o.db, "storage")
if e != nil {
return "", errors.New("读取存储配置失败")
}
c := *v.(*StorageConfig)
if c.Backend == "local" {
return "", nil
}
id := blobObjectID(hash, public)
// hash 相同字节必然相同:对象已存在时直接复用,省掉整次上传
var existing model.StoredObject
if e := o.db.First(&existing, "id = ?", id).Error; e == nil {
return id, nil
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
return "", e
}
client, e := o.s3(c)
if e != nil {
return "", e
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
f, e := os.Open(path)
if e != nil {
return "", e
}
stat, e := f.Stat()
if e != nil {
_ = f.Close()
return "", e
}
key := c.Prefix + "objects/" + id
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
_ = f.Close()
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
}
_ = f.Close()
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
if e = o.db.Create(&obj).Error; e != nil {
if isUniqueConflict(e) { // 并发:另一请求已写入同内容对象,直接复用
return id, nil
}
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
return "", errors.New("保存文件记录失败")
}
return id, nil
}
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
var obj model.StoredObject
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {

View File

@@ -72,18 +72,18 @@ type NewUserItem struct {
// OverviewData 首页聚合数据
type OverviewData struct {
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Announcements []AnnouncementItem `json:"announcements"`
AnnouncementsTotal int64 `json:"announcements_total"`
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
Ads []PublicAdItem `json:"ads"`
AdsPanelTitle string `json:"ads_panel_title"`
AdsEnabled bool `json:"ads_enabled"`
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Announcements []AnnouncementItem `json:"announcements"`
AnnouncementsTotal int64 `json:"announcements_total"`
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
Ads []PublicAdItem `json:"ads"`
AdsPanelTitle string `json:"ads_panel_title"`
AdsEnabled bool `json:"ads_enabled"`
Sponsors []PublicSponsorItem `json:"sponsors"`
SponsorsPanelTitle string `json:"sponsors_panel_title"`
SponsorsEnabled bool `json:"sponsors_enabled"`

View File

@@ -457,37 +457,37 @@ type PostAttachmentDTO struct {
// PostDetail 帖子详情(含可见性裁剪与附件)
type PostDetail struct {
ID uint `json:"id"`
BoardID uint `json:"board_id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
ContentAccess string `json:"content_access"`
AccessPoints int `json:"access_points"`
TypeMeta string `json:"type_meta"`
TypeStatus string `json:"type_status,omitempty"`
Pinned int `json:"pinned"`
Recommended bool `json:"recommended"`
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `json:"status"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
ID uint `json:"id"`
BoardID uint `json:"board_id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
ContentAccess string `json:"content_access"`
AccessPoints int `json:"access_points"`
TypeMeta string `json:"type_meta"`
TypeStatus string `json:"type_status,omitempty"`
Pinned int `json:"pinned"`
Recommended bool `json:"recommended"`
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `json:"status"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
// 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
ContentLocked bool `json:"content_locked"`
AccessHint string `json:"access_hint,omitempty"`
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
ContentLocked bool `json:"content_locked"`
AccessHint string `json:"access_hint,omitempty"`
Attachments []PostAttachmentDTO `json:"attachments"`
Question *QuestionState `json:"question,omitempty"`
Poll *PollState `json:"poll,omitempty"`
@@ -620,7 +620,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
Board: post.Board, User: post.User,

View File

@@ -2,10 +2,12 @@ package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"unicode/utf8"
@@ -145,8 +147,9 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
if err != nil {
return nil, err
}
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」;落盘同时计算内容 hash
hasher := sha256.New()
written, copyErr := io.Copy(io.MultiWriter(f, hasher), io.LimitReader(src, maxB+1))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
@@ -167,28 +170,45 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
return nil, err
}
att := &model.PostAttachment{
PostID: 0,
UserID: userID,
Name: name,
StoredName: stored,
MIME: mimeType,
Size: int(written),
PricePoints: pricePoints,
}
// 内容寻址:私有 blob 以 hash 命名(不带扩展名,下载时以 att.MIME + 原文件名下发)
hash := hex.EncodeToString(hasher.Sum(nil))
final := absPath(s.dir, hash)
var objectID string
if s.ops != nil {
id, e := s.ops.StoreFile(full, att.MIME, false)
id, e := s.ops.StoreFileHashed(full, mimeType, false, hash)
if e != nil {
_ = os.Remove(full)
return nil, e
}
att.ObjectID = id
objectID = id
if id != "" {
_ = os.Remove(full)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(full)
if objectID == "" {
if e := placeLocalBlob(full, final); e != nil {
return nil, e
}
}
att := &model.PostAttachment{
PostID: 0,
UserID: userID,
Name: name,
StoredName: hash,
BlobHash: hash,
ObjectID: objectID,
MIME: mimeType,
Size: int(written),
PricePoints: pricePoints,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if e := acquireBlobRef(tx, hash, false, int(written), mimeType, objectID); e != nil {
return e
}
return tx.Create(att).Error
}); err != nil {
s.releasePostBlobBestEffort(hash)
return nil, err
}
return att, nil
@@ -283,14 +303,51 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
if att.UserID != userID {
return ErrAttachmentForbidden
}
path := absPath(s.dir, att.StoredName)
if err := s.db.Delete(&att).Error; err != nil {
return err
}
if att.BlobHash != "" {
return s.releasePostBlob(&att)
}
if s.ops != nil && att.ObjectID != "" {
return s.ops.RemoveObject(att.ObjectID)
}
_ = os.Remove(path)
_ = os.Remove(absPath(s.dir, att.StoredName))
return nil
}
// releasePostBlob 私有附件 blob 引用计数-1;最后一个引用删除时回收物理文件
func (s *PostFileService) releasePostBlob(att *model.PostAttachment) error {
b, last, err := releaseBlobRef(s.db, att.BlobHash, false)
if err != nil || !last {
return err
}
return s.removePrivateBlobObject(b)
}
// releasePostBlobBestEffort 附件记录落库失败时回滚刚加的引用,错误只记日志
func (s *PostFileService) releasePostBlobBestEffort(hash string) {
b, last, err := releaseBlobRef(s.db, hash, false)
if err != nil {
log.Printf("[post-file] 回滚 blob 引用失败 hash=%s: %v", hash, err)
return
}
if !last {
return
}
if err := s.removePrivateBlobObject(b); err != nil {
log.Printf("[post-file] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
}
}
// removePrivateBlobObject 引用归零后的物理回收(私有 blob 文件名即 hash,无扩展名)
func (s *PostFileService) removePrivateBlobObject(b model.Blob) error {
if b.ObjectID != "" && s.ops != nil {
return s.ops.RemoveObject(b.ObjectID)
}
if err := os.Remove(absPath(s.dir, b.Hash)); err != nil && !os.IsNotExist(err) {
log.Printf("[post-file] 删除 blob 文件失败 hash=%s: %v", b.Hash, err)
}
return nil
}

View File

@@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
type AcceptedAnswer struct {
ID uint `json:"id"`
Floor int `json:"floor"`
IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id}
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Deleted bool `json:"deleted,omitempty"`
@@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
ans := &AcceptedAnswer{
ID: c.ID,
Floor: floor,
IsRoot: c.ParentID == nil,
CreatedAt: c.CreatedAt,
}
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID

View File

@@ -85,6 +85,7 @@ const (
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
RateCaptcha = "captcha" // 验证码刷新 30/分钟
RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP)
RateSnippet = "snippet" // 代码片段创建/Fork 10/分钟
)
// DefaultRateLimiter 创建默认速率限制器
@@ -102,5 +103,6 @@ func DefaultRateLimiter() *RateLimiter {
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟
rl.SetLimit(RateSnippet, 10) // 代码片段 10/分钟
return rl
}

View File

@@ -66,7 +66,7 @@ const (
SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分
// 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值)
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7
SettingKeyPointsStreakBonus = "points_streak_bonus" // 触发加成时额外加分;缺行=0
SettingKeyPointsPostReward = "points_post_reward" // 发帖奖励;0=关闭;缺行=0
@@ -80,6 +80,13 @@ const (
// SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开)
SettingKeyLevelsFx = "levels_fx"
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
SettingKeyUrlStyle = "url_style"
// SettingKeyMobileTabs 移动端底栏显示的 tab(JSON []string,元素取值见 MobileTabKeys);
// 缺行/非法/为空视为全量默认
SettingKeyMobileTabs = "mobile_tabs"
)
const (
@@ -112,8 +119,22 @@ const (
DefaultPointsCheckinBase = 5
DefaultPointsStreakEveryDays = 7
MaxPointsRule = 100 // 各积分规则值/上限的统一上界
// DefaultUrlStyle 标准动态路由风格(/post/123)
DefaultUrlStyle = "default"
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
MaxMobileTabs = 5
)
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认全量配置
func DefaultMobileTabs() []string {
return append([]string(nil), MobileTabKeys...)
}
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
var DefaultAttachmentExts = []string{
"pdf", "txt", "md", "csv", "json",
@@ -127,6 +148,9 @@ var DefaultAttachmentExts = []string{
var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
// UrlStyles 伪静态风格白名单,与前端 lib/urlStyle.ts 保持一致
var UrlStyles = []string{"default", "html", "discuz"}
// ErrInvalidAccent 主题色格式非法
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
@@ -137,34 +161,34 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
type PublicSiteSettings struct {
Accent string `json:"accent"`
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
PublishWithoutReview bool `json:"publish_without_review"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
CommentsRequireLogin bool `json:"comments_require_login"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
UIAnimations bool `json:"ui_animations"`
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
Accent string `json:"accent"`
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
PublishWithoutReview bool `json:"publish_without_review"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
CommentsRequireLogin bool `json:"comments_require_login"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
UIAnimations bool `json:"ui_animations"`
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
// 内容锁定与旧帖回复(0=关闭;公开保持规则透明)
PostEditLockHours int `json:"post_edit_lock_hours"`
CommentEditLockHours int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
NecroReplyPenalty int `json:"necro_reply_penalty"`
PostEditLockHours int `json:"post_edit_lock_hours"`
CommentEditLockHours int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
NecroReplyPenalty int `json:"necro_reply_penalty"`
BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"`
@@ -178,6 +202,7 @@ type PublicSiteSettings struct {
BrandMark string `json:"brand_mark"`
BrandLogoSize string `json:"brand_logo_size"`
BrandLogoFit string `json:"brand_logo_fit"`
BrandSloganVisible bool `json:"brand_slogan_visible"`
FooterLinks []FooterLink `json:"footer_links"`
// 积分增长规则(经济规则公开透明,签到卡等可直出)
@@ -194,6 +219,15 @@ type PublicSiteSettings struct {
Levels []model.LevelDef `json:"levels"`
// LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色)
LevelsFx bool `json:"levels_fx"`
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
UrlStyle string `json:"url_style"`
// MobileTabs 移动端底栏显示的 tab(有序子集,数组顺序即展示顺序);空/非法由读取端回落默认
MobileTabs []string `json:"mobile_tabs"`
// HeaderNav 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项
HeaderNav []HeaderNavItem `json:"header_nav"`
}
// SettingService 站点级键值设置
@@ -297,6 +331,8 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
BrandLogoSize: BrandLogoSizeSQ,
BrandLogoFit: BrandLogoFitContain,
FooterLinks: []FooterLink{},
UrlStyle: DefaultUrlStyle,
HeaderNav: DefaultHeaderNav(),
}
accent, err := s.AccentColor()
if err != nil {
@@ -494,12 +530,131 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
return out, err
}
out.BgAdminMode = adminMode
urlStyle, err := s.UrlStyle()
if err != nil {
return out, err
}
out.UrlStyle = urlStyle
out.MobileTabs = s.MobileTabs()
out.HeaderNav = s.HeaderNav()
if err := s.fillBrand(&out); err != nil {
return out, err
}
return out, nil
}
// UrlStyle 返回详情页伪静态风格;缺行或值非法时回落 default
func (s *SettingService) UrlStyle() (string, error) {
v, found, err := s.getValue(SettingKeyUrlStyle)
if err != nil {
return DefaultUrlStyle, err
}
if !found {
return DefaultUrlStyle, nil
}
style := strings.TrimSpace(strings.ToLower(v))
for _, valid := range UrlStyles {
if style == valid {
return style, nil
}
}
return DefaultUrlStyle, nil
}
// SetUrlStyle 校验并保存伪静态风格;传 default 表示恢复标准风格(删除该键)
func (s *SettingService) SetUrlStyle(style string) error {
style = strings.TrimSpace(strings.ToLower(style))
if style == DefaultUrlStyle {
return s.deleteKey(SettingKeyUrlStyle)
}
for _, valid := range UrlStyles {
if style == valid {
return s.putValue(SettingKeyUrlStyle, style)
}
}
return ErrInvalidSiteSetting
}
// normalizeMobileTabs 校验底栏 tab 列表:去空白、去重、只留白名单内元素
func normalizeMobileTabs(tabs []string) ([]string, bool) {
allowed := make(map[string]struct{}, len(MobileTabKeys))
for _, k := range MobileTabKeys {
allowed[k] = struct{}{}
}
seen := make(map[string]struct{}, len(tabs))
out := make([]string, 0, len(tabs))
for _, t := range tabs {
k := strings.TrimSpace(strings.ToLower(t))
if k == "" {
continue
}
if _, ok := allowed[k]; !ok {
return nil, false
}
if _, dup := seen[k]; dup {
continue
}
seen[k] = struct{}{}
out = append(out, k)
if len(out) > MaxMobileTabs {
return nil, false
}
}
return out, true
}
// mobileTabsEqual 判断两个列表是否元素一致(忽略顺序)
func mobileTabsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
set := make(map[string]struct{}, len(b))
for _, k := range b {
set[k] = struct{}{}
}
for _, k := range a {
if _, ok := set[k]; !ok {
return false
}
}
return true
}
// MobileTabs 移动端底栏配置;缺行/为空/非法/解析失败回落默认全量
func (s *SettingService) MobileTabs() []string {
v, found, err := s.getValue(SettingKeyMobileTabs)
if err != nil || !found || strings.TrimSpace(v) == "" {
return DefaultMobileTabs()
}
var tabs []string
if err := json.Unmarshal([]byte(v), &tabs); err != nil {
return DefaultMobileTabs()
}
out, ok := normalizeMobileTabs(tabs)
if !ok || len(out) == 0 {
return DefaultMobileTabs()
}
return out
}
// SetMobileTabs 校验并保存底栏配置;与默认全量一致时删键保持「缺行=默认」语义
func (s *SettingService) SetMobileTabs(tabs []string) error {
out, ok := normalizeMobileTabs(tabs)
if !ok || len(out) == 0 {
return ErrInvalidSiteSetting
}
if mobileTabsEqual(out, MobileTabKeys) {
return s.deleteKey(SettingKeyMobileTabs)
}
b, err := json.Marshal(out)
if err != nil {
return err
}
return s.putValue(SettingKeyMobileTabs, string(b))
}
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
func (s *SettingService) AccentColor() (string, error) {
v, found, err := s.getValue(SettingKeyAccent)

View File

@@ -11,7 +11,10 @@ import (
"gorm.io/gorm"
)
var sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
var (
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除")
)
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
var sitePageWriteFields = []string{
@@ -116,16 +119,27 @@ func (in *SitePageInput) applyTo(p *model.SitePage) {
}
}
func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) {
q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug)
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
// active=在用单页占用;deleted=已删除单页占用
func (s *SitePageService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
q := s.db.Unscoped().Model(&model.SitePage{}).Where("slug = ?", slug).Select("deleted_at")
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var n int64
if err := q.Count(&n).Error; err != nil {
return false, err
var rows []struct {
DeletedAt gorm.DeletedAt
}
return n > 0, nil
if err := q.Find(&rows).Error; err != nil {
return false, false, err
}
for _, r := range rows {
if r.DeletedAt.Valid {
deleted = true
} else {
active = true
}
}
return active, deleted, nil
}
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
@@ -179,11 +193,23 @@ func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, erro
return &p, nil
}
// ListAll 管理后台全部单页
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
var list []model.SitePage
err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error
return list, err
// SitePageAdminItem 管理端单页(附 deleted 标记;DeletedAt json:"-" 不出模型)
type SitePageAdminItem struct {
model.SitePage
Deleted bool `json:"deleted"`
}
// ListAll 管理后台全部单页(含已软删,仅供查看与彻底删除)
func (s *SitePageService) ListAll() ([]SitePageAdminItem, error) {
var pages []model.SitePage
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(100).Find(&pages).Error; err != nil {
return nil, err
}
out := make([]SitePageAdminItem, 0, len(pages))
for _, p := range pages {
out = append(out, SitePageAdminItem{SitePage: p, Deleted: p.DeletedAt.Valid})
}
return out, nil
}
// Get 管理后台按 ID 取单页
@@ -200,13 +226,16 @@ func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
if err := in.normalize(nil); err != nil {
return nil, err
}
taken, err := s.slugTaken(in.Slug, 0)
active, deleted, err := s.slugTaken(in.Slug, 0)
if err != nil {
return nil, err
}
if taken {
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
}
p := &model.SitePage{
Published: false,
ShowInSidebar: false,
@@ -229,13 +258,16 @@ func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, e
if err := in.normalize(&p); err != nil {
return nil, err
}
taken, err := s.slugTaken(in.Slug, id)
active, deleted, err := s.slugTaken(in.Slug, id)
if err != nil {
return nil, err
}
if taken {
if active {
return nil, errors.New("slug 已被占用")
}
if deleted {
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
}
in.applyTo(&p)
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
@@ -264,3 +296,15 @@ func (s *SitePageService) Delete(id uint) error {
}
return nil
}
// Purge 彻底删除已软删的单页(slug 释放可复用)
func (s *SitePageService) Purge(id uint) error {
var p model.SitePage
if err := s.db.Unscoped().First(&p, id).Error; err != nil {
return gorm.ErrRecordNotFound
}
if !p.DeletedAt.Valid {
return ErrSitePageNotDeleted
}
return s.db.Unscoped().Delete(&p).Error
}

View File

@@ -0,0 +1,69 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newSitePageTestService(t *testing.T) *SitePageService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.SitePage{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewSitePageService(db)
}
func sitePageInput(slug, title string) *SitePageInput {
return &SitePageInput{Slug: slug, Title: title, Content: "正文内容"}
}
func TestSitePageSlugReuseAfterSoftDelete(t *testing.T) {
s := newSitePageTestService(t)
p, err := s.Create(sitePageInput("test", "测试"))
if err != nil {
t.Fatalf("create: %v", err)
}
if err := s.Delete(p.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
_, err = s.Create(sitePageInput("test", "重建"))
if err == nil || !strings.Contains(err.Error(), "已删除") {
t.Fatalf("软删后重建应提示已删除单页占用,got %v", err)
}
// 管理端列表含软删行且带 deleted 标记
all, err := s.ListAll()
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(all) != 1 || !all[0].Deleted || all[0].Slug != "test" {
t.Fatalf("ListAll 应含软删单页且 Deleted=true,got %+v", all)
}
// 彻底删除后可重建
if err := s.Purge(p.ID); err != nil {
t.Fatalf("purge: %v", err)
}
if _, err := s.Create(sitePageInput("test", "重建")); err != nil {
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
}
}
func TestSitePagePurgeRequiresDeleted(t *testing.T) {
s := newSitePageTestService(t)
p, _ := s.Create(sitePageInput("alive", "在用"))
if err := s.Purge(p.ID); err == nil || !errors.Is(err, ErrSitePageNotDeleted) {
t.Fatalf("未删单页 Purge 应拒绝,got %v", err)
}
}

1022
backend/service/snippet.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,371 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
)
const (
timelineReleaseMaxPages = 10
timelineReleaseMaxItems = 100
)
// timelineReleaseSource 内置 Release 适配(Gitea + GitHub)
type timelineReleaseSource struct {
id string
host string // 精确主机或 "*"(通配)
listPath string // releases 页正则
apiURL string // API 模板
query map[string]string
headers map[string]string
pagination string // link_header | query_page
dateField string // 优先取的字段:published_at / created_at
}
var timelineReleaseSources = []timelineReleaseSource{
{
id: "github_release",
host: "github.com",
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
apiURL: "https://api.github.com/repos/{owner}/{repo}/releases",
query: map[string]string{"per_page": "100", "page": "{page}"},
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json"},
pagination: "link_header",
dateField: "published_at",
},
{
id: "gitea_release",
host: "*",
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
apiURL: "https://{host}/api/v1/repos/{owner}/{repo}/releases",
query: map[string]string{"limit": "50", "page": "{page}"},
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/json"},
pagination: "query_page",
dateField: "created_at",
},
}
// matchReleaseSource 按 host + path 匹配内置 release 适配
func matchReleaseSource(host, path string) (*timelineReleaseSource, map[string]string, error) {
for i := range timelineReleaseSources {
src := &timelineReleaseSources[i]
if src.host != "*" && !strings.EqualFold(src.host, host) {
continue
}
if src.host == "*" && strings.EqualFold(host, "github.com") {
continue
}
re, err := regexp.Compile(src.listPath)
if err != nil {
continue
}
if m := re.FindStringSubmatch(path); m != nil {
return src, subexpMap(re, m), nil
}
}
return nil, nil, errors.New("地址不符或主机未配置")
}
// ImportTimelineFromReleases 按 releases 页 URL 拉取并解析发布记录
func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGitImportResult, error) {
cleanURLs := make([]string, 0, len(urls))
for _, u := range urls {
u = strings.TrimSpace(u)
if u != "" {
cleanURLs = append(cleanURLs, u)
}
}
if len(cleanURLs) == 0 {
return nil, errors.New("请提供至少一条 URL")
}
if len(cleanURLs) > 20 {
return nil, errors.New("一次最多 20 条 URL")
}
client := &http.Client{
Timeout: timelineGitHTTPTimeout,
Transport: &http.Transport{
DialContext: publicOnlyDial,
TLSHandshakeTimeout: timelineGitHTTPTimeout,
ForceAttemptHTTP2: true,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("重定向过多")
}
if err := assertSafeHTTPSURL(req.URL); err != nil {
return err
}
return nil
},
}
seenURL := map[string]bool{}
var items []TimelineGitItem
var failMsgs []string
truncated := false
for _, rawURL := range cleanURLs {
part, partTrunc, err := s.importOneReleaseURL(client, rawURL, seenURL, timelineReleaseMaxItems-len(items))
if err != nil {
failMsgs = append(failMsgs, fmt.Sprintf("%s:%s", truncateTimelineStr(rawURL, 80), err.Error()))
continue
}
items = append(items, part...)
if partTrunc {
truncated = true
}
if len(items) >= timelineReleaseMaxItems {
truncated = true
break
}
}
out := &TimelineGitImportResult{Items: items}
if truncated {
out.Warning = fmt.Sprintf("已达上限(最多 %d 条),可再贴后续页 URL", timelineReleaseMaxItems)
}
if len(failMsgs) > 0 {
out.Error = strings.Join(failMsgs, ";")
}
if len(items) == 0 && out.Error == "" {
out.Error = "未能解析出 Release"
}
return out, nil
}
func (s *SettingService) importOneReleaseURL(
client *http.Client,
rawURL string,
seenURL map[string]bool,
remain int,
) ([]TimelineGitItem, bool, error) {
if remain <= 0 {
return nil, true, nil
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, false, errors.New("URL 无效")
}
if err := assertSafeHTTPSURL(u); err != nil {
return nil, false, err
}
host := strings.ToLower(u.Hostname())
path := u.EscapedPath()
if path == "" {
path = "/"
}
src, caps, err := matchReleaseSource(host, path)
if err != nil {
return nil, false, err
}
if !repoNameRe.MatchString(caps["owner"]) || !repoNameRe.MatchString(caps["repo"]) {
return nil, false, errors.New("仓库名非法")
}
startPage := 1
if p := u.Query().Get("page"); p != "" {
if n, e := parseIntPage(p); e == nil && n >= 1 {
startPage = n
}
}
var out []TimelineGitItem
truncated := false
page := startPage
maxPages := timelineReleaseMaxPages
pagesDone := 0
nextURL := ""
for pagesDone < maxPages && len(out) < remain {
var apiURL string
if nextURL != "" {
apiURL = nextURL
nextURL = ""
} else {
apiURL = expandTemplate(src.apiURL, host, caps, page)
q := url.Values{}
for k, v := range src.query {
q.Set(k, expandTemplate(v, host, caps, page))
}
parsed, e := url.Parse(apiURL)
if e != nil {
return out, truncated, errors.New("API URL 无效")
}
if len(q) > 0 {
existing := parsed.Query()
for k, vs := range q {
existing.Set(k, vs[0])
}
parsed.RawQuery = existing.Encode()
}
apiURL = parsed.String()
}
parsedAPI, err := url.Parse(apiURL)
if err != nil {
return out, truncated, errors.New("API URL 无效")
}
if err := assertSafeHTTPSURL(parsedAPI); err != nil {
return out, truncated, err
}
headers := http.Header{}
for k, v := range src.headers {
if allowedAdapterHeaders[strings.ToLower(k)] {
headers.Set(k, v)
}
}
if headers.Get("User-Agent") == "" {
headers.Set("User-Agent", "jiang13-bbs")
}
body, linkNext, status, err := httpGetLimited(client, parsedAPI.String(), headers)
if err != nil {
return out, truncated, err
}
if status == 404 || status == 401 || status == 403 {
return out, truncated, errors.New("无法读取该仓库(私有、不存在或无权访问)")
}
if status == 429 {
return out, truncated, errors.New("远端限流,请稍后再试")
}
if status < 200 || status >= 300 {
return out, truncated, fmt.Errorf("远端返回 %d", status)
}
pageItems, err := parseReleaseListJSON(body, src, host)
if err != nil {
return out, truncated, err
}
if len(pageItems) == 0 {
break
}
for _, it := range pageItems {
if it.SourceURL != "" && seenURL[it.SourceURL] {
continue
}
if it.SourceURL != "" {
seenURL[it.SourceURL] = true
}
out = append(out, it)
if len(out) >= remain {
truncated = true
break
}
}
pagesDone++
if src.pagination == "link_header" && linkNext != "" {
nu, e := url.Parse(linkNext)
if e != nil || assertSafeHTTPSURL(nu) != nil {
break
}
nextURL = nu.String()
} else if src.pagination == "query_page" {
page++
} else {
break
}
}
if pagesDone >= maxPages {
truncated = true
}
return out, truncated, nil
}
func parseReleaseListJSON(body []byte, src *timelineReleaseSource, host string) ([]TimelineGitItem, error) {
var root any
if err := json.Unmarshal(body, &root); err != nil {
return nil, errors.New("响应非 JSON")
}
arr, ok := root.([]any)
if !ok {
return nil, errors.New("响应不是 Release 列表")
}
var out []TimelineGitItem
for _, el := range arr {
item, ok := mapReleaseObject(el, src, host)
if !ok {
continue
}
out = append(out, item)
}
return out, nil
}
func mapReleaseObject(el any, src *timelineReleaseSource, host string) (TimelineGitItem, bool) {
m, ok := el.(map[string]any)
if !ok {
return TimelineGitItem{}, false
}
// 跳过草稿
if draft, ok := m["draft"].(bool); ok && draft {
return TimelineGitItem{}, false
}
name := jsonStringField(m, "name")
tag := jsonStringField(m, "tag_name")
title := strings.TrimSpace(name)
if title == "" {
title = strings.TrimSpace(tag)
}
if title == "" {
return TimelineGitItem{}, false
}
body := jsonStringField(m, "body")
dateRaw := jsonStringField(m, src.dateField)
if dateRaw == "" {
// 兜底用 created_at
dateRaw = jsonStringField(m, "created_at")
}
srcURL := jsonStringField(m, "html_url")
title = sanitizeTimelinePlain(title, timelineTitleMax)
body = sanitizeTimelinePlain(body, timelineBodyMax)
title = neutralizeDirectivePlain(title)
body = neutralizeDirectivePlain(body)
date := parseCommitDate(dateRaw)
srcURL = sanitizeSourceURL(srcURL, host)
return TimelineGitItem{
Date: date,
Title: title,
Body: body,
SourceURL: srcURL,
}, true
}
// jsonStringField 从 map[string]any 取字符串字段,兼容 string/number
func jsonStringField(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case float64:
return fmt.Sprintf("%v", x)
case json.Number:
return x.String()
default:
return fmt.Sprintf("%v", x)
}
}
func parseIntPage(s string) (int, error) {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, errors.New("page 非数字")
}
n = n*10 + int(r-'0')
if n > 1_000_000 {
return 0, errors.New("page 过大")
}
}
return n, nil
}

View File

@@ -19,6 +19,7 @@ import (
"time"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/webp"
"gorm.io/gorm"
)
@@ -29,10 +30,13 @@ const (
AvatarMinDim = 64
AvatarMaxDim = 512
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
ImageMaxBytes = 5 << 20 // 5 MiB
// 帖子插图:JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
ImageMaxBytes = 5 << 20 // 5 MiB(对原始上传文件生效)
ImageMaxDim = 4096
// JPEG 转 WebP 的有损质量;PNG 走无损,不受此参数影响
ImageWebPQuality = 80
// 站点背景图(前台/后台墙纸)
BackgroundMaxBytes = 8 << 20 // 8 MiB
BackgroundMaxDim = 8192
@@ -109,46 +113,57 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
return nil, errors.New("头像尺寸需在 64~512px 之间")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return nil, err
}
filename := hex.EncodeToString(nameBytes) + ".webp"
hash := sha256Hex(data)
filename := hash + ".webp"
fullPath := filepath.Join(s.dir, "avatars", filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return nil, err
url := "/uploads/avatars/" + filename
var objectID string
writeLocal := func() error {
if _, err := os.Stat(fullPath); err == nil {
return nil // 同内容头像已落盘,字节必然一致,无需重写
}
return os.WriteFile(fullPath, data, 0o644)
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: "/uploads/avatars/" + filename,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if err := writeLocal(); err != nil {
return nil, err
}
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
objectID = id
if id != "" {
att.URL = "/api/media/" + id
url = "/api/media/" + id
_ = os.Remove(fullPath)
}
} else if err := writeLocal(); err != nil {
return nil, err
}
// 附件记录与用户头像更新在同一事务内完成
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: url,
BlobHash: hash,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
// 附件记录、blob 引用与用户头像更新在同一事务内完成
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
return err
}
if err := tx.Create(att).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
})
if err != nil {
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
return nil, err
}
return att, nil
@@ -190,8 +205,51 @@ func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
return cfg.Width, cfg.Height, nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
// transcodeImageToWebP 把校验通过的 JPEG/PNG 临时文件解码后转码为 WebP:
// JPEG 走有损 q80,PNG 走无损(保留透明通道与文字锐度)。
// 成功返回新文件名/MIME/大小并清理原临时文件;失败返回 error(调用方保留原格式落盘)。
func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, string, int64, error) {
webpName := strings.TrimSuffix(name, ext) + ".webp"
webpPath := filepath.Join(s.dir, "images", webpName)
webpTmp := webpPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(webpTmp)
if e != nil {
return "", "", 0, e
}
defer release()
}
f, err := os.Open(tmp)
if err != nil {
return "", "", 0, err
}
img, _, err := image.Decode(f) // 仅 JPEG/PNG 会走到这里,解码器已在文件头注册
_ = f.Close()
if err != nil {
return "", "", 0, err
}
var buf bytes.Buffer
if err := webpenc.Encode(&buf, img, webpenc.Options{
Quality: ImageWebPQuality,
Lossless: ext == ".png",
}); err != nil {
return "", "", 0, err
}
if err := os.WriteFile(webpTmp, buf.Bytes(), 0o644); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
if err := os.Rename(webpTmp, webpPath); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
_ = os.Remove(tmp)
return webpName, "image/webp", int64(buf.Len()), nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*model.Attachment, error) {
if src == nil {
return nil, errors.New("文件为空")
}
@@ -284,33 +342,71 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
_ = os.Remove(tmp)
return nil, errors.New("图片边长不能超过 4096px")
}
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
// JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
storeName, storeMime, storeSize := filename, format.mime, total
storePath := fullPath
if format.mime == "image/jpeg" || format.mime == "image/png" {
name, mime, size, err := s.transcodeImageToWebP(tmp, filename, format.ext)
if err != nil {
log.Printf("[upload] 插图转 WebP 失败,保留原格式 %s: %v", filename, err)
} else {
storeName, storeMime, storeSize = name, mime, size
storePath = filepath.Join(s.dir, "images", name)
}
}
if storePath == fullPath {
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return nil, err
}
}
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
hash, err := hashFile(storePath)
if err != nil {
_ = os.Remove(storePath)
return nil, err
}
finalName := hash + strings.ToLower(filepath.Ext(storeName))
url := "/uploads/images/" + finalName
var objectID string
if s.ops != nil {
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
if e != nil {
_ = os.Remove(storePath)
return nil, e
}
objectID = id
if id != "" {
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
url = "/api/media/" + id
_ = os.Remove(storePath)
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
return nil, e
}
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
return nil, e
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
URL: "/uploads/images/" + filename,
MIME: format.mime,
Size: int(total),
Width: w,
Height: h,
UserID: userID,
Kind: model.AttachmentKindImage,
Source: normalizeImageSource(source),
URL: url,
BlobHash: hash,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
return err
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(fullPath)
return tx.Create(att).Error
}); err != nil {
s.releaseBlobBestEffort(hash, true, url)
return nil, err
}
return att, nil
@@ -523,6 +619,46 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
return s.SaveBackground(f)
}
// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post
func normalizeImageSource(s string) string {
switch s {
case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent:
return s
default:
return model.AttachmentSourcePost
}
}
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
// source 决定副本的来源标记(如 library_cover)
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*model.Attachment, error) {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return nil, errors.New("图片不存在或不属于你")
}
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
if e != nil {
return nil, e
}
defer r.Close()
return s.SaveImage(userID, r, source)
}
abs, ok := s.safeUploadPath(att.URL)
if !ok {
return nil, errors.New("无效的图片地址")
}
f, err := os.Open(abs)
if err != nil {
return nil, errors.New("读取图片失败")
}
defer f.Close()
return s.SaveImage(userID, f, source)
}
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
func (s *UploadService) BackgroundFileExists(url string) bool {
rel := BgRelPath(url)
@@ -576,6 +712,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
return list, err
}
// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error)
func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil {
return nil, err
}
return &att, nil
}
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
@@ -610,6 +755,10 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
return err
}
if att.BlobHash != "" {
return s.releaseAttachmentBlob(&att)
}
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
@@ -624,4 +773,47 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
return nil
}
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
if err != nil || !last {
return err
}
return s.removePublicBlobObject(b, att.URL)
}
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
b, last, err := releaseBlobRef(s.db, hash, public)
if err != nil {
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
return
}
if !last {
return
}
if err := s.removePublicBlobObject(b, localURL); err != nil {
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
}
}
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
if b.ObjectID != "" && s.ops != nil {
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
}
return nil
}
if localURL == "" {
return nil
}
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
}
return nil
}
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }

View File

@@ -0,0 +1,172 @@
package service
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"github.com/glebarez/sqlite"
xwebp "golang.org/x/image/webp"
"gorm.io/gorm"
)
func newUploadTestService(t *testing.T) (*UploadService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func storedUploadPath(t *testing.T, dir, url string) []byte {
t.Helper()
rel := strings.TrimPrefix(url, "/uploads/")
data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("read stored file %s: %v", url, err)
}
return data
}
func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
for y := 0; y < 36; y++ {
for x := 0; x < 48; x++ {
src.Set(x, y, color.RGBA{uint8(x * 5), uint8(y * 7), 128, 255})
}
}
var in bytes.Buffer
if err := jpeg.Encode(&in, src, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
if att.Width != 48 || att.Height != 36 {
t.Fatalf("dims changed: %dx%d", att.Width, att.Height)
}
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
if err != nil {
t.Fatalf("decode stored webp: %v", err)
}
if got := img.Bounds(); got.Dx() != 48 || got.Dy() != 36 {
t.Fatalf("stored webp dims: %v", got)
}
}
func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewNRGBA(image.Rect(0, 0, 24, 24))
for y := 0; y < 24; y++ {
for x := 0; x < 24; x++ {
if y < 12 {
src.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 0}) // 上半透明
} else {
src.SetNRGBA(x, y, color.NRGBA{12, 34, 56, 255})
}
}
}
var in bytes.Buffer
if err := png.Encode(&in, src); err != nil {
t.Fatalf("encode png: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
if err != nil {
t.Fatalf("decode stored webp: %v", err)
}
if _, _, _, a := img.At(0, 0).RGBA(); a != 0 {
t.Fatalf("alpha lost at transparent pixel: %d", a)
}
r, g, b, _ := img.At(5, 20).RGBA()
if r>>8 != 12 || g>>8 != 34 || b>>8 != 56 {
t.Fatalf("opaque color changed: %d,%d,%d", r>>8, g>>8, b>>8)
}
}
func TestSaveImageKeepsGIFAsIs(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 16, 16))
for y := 0; y < 16; y++ {
for x := 0; x < 16; x++ {
src.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 0, 255})
}
}
var in bytes.Buffer
if err := gif.Encode(&in, src, nil); err != nil {
t.Fatalf("encode gif: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".gif") || att.MIME != "image/gif" {
t.Fatalf("gif should be kept as-is, got url=%s mime=%s", att.URL, att.MIME)
}
data := storedUploadPath(t, dir, att.URL)
magic := string(data[:6])
if magic != "GIF89a" && magic != "GIF87a" {
t.Fatalf("stored gif magic invalid: %q", magic)
}
}
func TestSaveImageKeepsWebPAsIs(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
var in bytes.Buffer
if err := webpenc.Encode(&in, src, webpenc.Options{Quality: ImageWebPQuality}); err != nil {
t.Fatalf("encode webp: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
if !bytes.Equal(storedUploadPath(t, dir, att.URL), in.Bytes()) {
t.Fatal("webp input should be stored byte-for-byte")
}
if att.Size != in.Len() {
t.Fatalf("size mismatch: att=%d input=%d", att.Size, in.Len())
}
}

View File

@@ -0,0 +1,112 @@
package service
import (
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newUrlStyleTestService(t *testing.T) *SettingService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.SiteSetting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewSettingService(db)
}
func TestUrlStyleDefaultWhenMissing(t *testing.T) {
s := newUrlStyleTestService(t)
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != DefaultUrlStyle {
t.Fatalf("missing row should fall back to %q, got %q", DefaultUrlStyle, got)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if pub.UrlStyle != DefaultUrlStyle {
t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, DefaultUrlStyle)
}
}
func TestSetUrlStyleRoundTrip(t *testing.T) {
s := newUrlStyleTestService(t)
for _, style := range []string{"html", "discuz"} {
if err := s.SetUrlStyle(style); err != nil {
t.Fatalf("SetUrlStyle(%q): %v", style, err)
}
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != style {
t.Fatalf("UrlStyle() = %q, want %q", got, style)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if pub.UrlStyle != style {
t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, style)
}
}
}
func TestSetUrlStyleDefaultDeletesKey(t *testing.T) {
s := newUrlStyleTestService(t)
if err := s.SetUrlStyle("html"); err != nil {
t.Fatal(err)
}
// 传 default 应删键,恢复「缺行=默认」语义(大小写与空白容错)
if err := s.SetUrlStyle(" DEFAULT "); err != nil {
t.Fatal(err)
}
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != DefaultUrlStyle {
t.Fatalf("after reset got %q, want %q", got, DefaultUrlStyle)
}
var rows int64
s.db.Where("key = ?", SettingKeyUrlStyle).Model(&model.SiteSetting{}).Count(&rows)
if rows != 0 {
t.Fatalf("default should delete the row, rows=%d", rows)
}
}
func TestSetUrlStyleRejectsInvalid(t *testing.T) {
s := newUrlStyleTestService(t)
for _, bad := range []string{"", "pretty", "HTML5", "thread", "../etc"} {
if err := s.SetUrlStyle(bad); err == nil {
t.Fatalf("SetUrlStyle(%q) should fail", bad)
}
}
// 非法值不应落库
if _, err := s.UrlStyle(); err != nil {
t.Fatal(err)
}
}
func TestUrlStyleInvalidStoredValueFallsBack(t *testing.T) {
s := newUrlStyleTestService(t)
if err := s.putValue(SettingKeyUrlStyle, "nonsense"); err != nil {
t.Fatal(err)
}
got, err := s.UrlStyle()
if err != nil {
t.Fatal(err)
}
if got != DefaultUrlStyle {
t.Fatalf("invalid stored value should fall back to %q, got %q", DefaultUrlStyle, got)
}
}

View File

@@ -29,3 +29,26 @@ func TestClassifyReferrer(t *testing.T) {
}
}
}
func TestDecodeSearchTerm(t *testing.T) {
// 「新人报道」的 UTF-8 百分号编码前缀,模拟 512 字节截断
news := "%E6%96%B0%E4%BA%BA%E6%8A%A5%E9%81%93"
cases := []struct {
raw string
want string
}{
{"%E4%BD%A0%E5%A5%BD", "你好"}, // 完整编码
{"a+b", "a b"}, // + 按空格解码
{"plain", "plain"}, // 无编码
{"2222%E5%A6%82%E6%9E%9C", "2222如果"}, // 完整转义结尾
{news + "%E6%96", "新人报道"}, // 截断后结尾是完整转义但只是半截字符 → 去非法 UTF-8 尾巴
{news + "%E6%96%9", "新人报道"}, // 一位十六进制的残缺转义 → 剥尾重试 + 去半截字符
{news + "%", "新人报道"}, // 截断在孤立的 %
{news + "%E", "新人报道"}, // 截断在 1 位十六进制
}
for _, c := range cases {
if got := decodeSearchTerm(c.raw); got != c.want {
t.Errorf("decodeSearchTerm(%q) = %q, want %q", c.raw, got, c.want)
}
}
}

View File

@@ -1,8 +1,11 @@
package service
import (
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
@@ -88,11 +91,11 @@ type VisitSourcesKindDay struct {
// VisitSources 来源分析 Tab
type VisitSources struct {
RangeDays int `json:"range_days"`
Kinds []VisitNamedCount `json:"kinds"`
SearchHost []VisitNamedCount `json:"search_hosts"`
External []VisitNamedCount `json:"external"`
Trend []VisitSourcesKindDay `json:"trend"`
RangeDays int `json:"range_days"`
Kinds []VisitNamedCount `json:"kinds"`
SearchTerms []VisitNamedCount `json:"search_terms"` // 站内搜索词 Top N(/?q= 结果页浏览)
External []VisitNamedCount `json:"external"`
Trend []VisitSourcesKindDay `json:"trend"`
}
// VisitPages 受访页面 Tab
@@ -336,8 +339,7 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
return nil, err
}
if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
"ref_kind = '"+RefKindSearch+"'", 20); err != nil {
if out.SearchTerms, err = s.siteSearchTerms(start, 10); err != nil {
return nil, err
}
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
@@ -350,6 +352,64 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
return out, nil
}
// siteSearchTerms 站内搜索词:聚合搜索结果页(/?q=…,path 记录完整 query)的浏览量。
// 词以 URL 编码存于 path,按编码串分组后解码展示(站内 encodeURIComponent 编码确定性强)。
func (s *VisitStatsService) siteSearchTerms(start time.Time, limit int) ([]VisitNamedCount, error) {
const expr = "substring(path from '[?&]q=([^&]*)')"
var rows []struct {
Key string
Count int64
}
if err := s.db.Model(&model.VisitEvent{}).
Select(expr+" AS key, COUNT(*) AS count").
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
Where(expr + " IS NOT NULL AND " + expr + " <> ''").
Group("key").Order("count DESC").Limit(limit).
Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]VisitNamedCount, 0, len(rows))
for _, r := range rows {
term := decodeSearchTerm(r.Key)
if runes := []rune(term); len(runes) > 30 {
term = string(runes[:30]) + "…"
}
out = append(out, VisitNamedCount{Key: term, Label: term, Count: r.Count})
}
return out, nil
}
// decodeSearchTerm 解码 path 里 q 参数值(URL 编码)。path 512 字节截断可能:
// 1) 把 %XX 转义拦腰切断(孤 % / 一位十六进制)→ 整串解码失败,剥掉残缺尾部重试;
// 2) 恰好切在多字节字符的转义序列中间 → 解码「成功」但尾部是半截字符,去掉非法 UTF-8 尾巴。
func decodeSearchTerm(raw string) string {
term := strings.ReplaceAll(raw, "+", " ")
dec, err := url.QueryUnescape(term)
if err != nil {
// 残缺转义(如结尾孤 % 或一位十六进制):剥掉最后一个 % 起的尾部重试
if i := strings.LastIndex(term, "%"); i > 0 {
dec, err = url.QueryUnescape(term[:i])
}
}
if err != nil {
return term
}
return validUTF8Prefix(dec)
}
// validUTF8Prefix 返回 s 的最长合法 UTF-8 前缀(截断产生的半截多字节字符会被去掉;
// 真实的 U+FFFD 字符合法保留,与非法字节可区分)。
func validUTF8Prefix(s string) string {
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size == 1 {
return s[:i]
}
i += size
}
return s
}
// SourcesTrend 来源按日明细(kind 分列行)
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
days := parseRangeDays(rangeQ)

View File

@@ -9,7 +9,10 @@ RUN go mod download
COPY backend/ ./
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
# -tags nodynamic:gen2brain/webp 默认经 purego dlopen libwebp.so,会把二进制动态链接到
# glibc(INTERP /lib64/ld-linux),Alpine 无法 exec(0.2.1 首版事故)。该标签强制走
# wasm2go 转译的纯 Go 实现,保持 CGO_ENABLED=0 全静态。
RUN CGO_ENABLED=0 GOOS=linux go build -tags nodynamic \
-trimpath \
-ldflags="-s -w -X github.com/freefire/jiang13-bbs/version.Version=${VERSION} -X github.com/freefire/jiang13-bbs/version.Commit=${COMMIT}" \
-o /out/jiang13 ./cmd/jiang13

BIN
docs/images/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
docs/images/zfb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
docs/images/书库.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
docs/images/首页.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

View File

@@ -0,0 +1,36 @@
# 旧站迁移公告:账号数据已迁入,旧站将全面搬家
各位社区成员:
**旧网站将在不久后全面迁移到姜十三论坛**,以后我们就在新家见面。
## 目前已迁移:用户账号
旧站账号数据已完成迁移,包括用户名、昵称、个性签名、邮箱和头像。
**无需重新注册**,直接用旧站的**用户名 + 原密码**登录即可——密码是原样迁移的,不需要重置。登录后建议在「账号设置」里核对一下资料。
![【图片占位】新站登录页截图](docs/screenshots/migration/login.png)
## 暂未迁移:帖子与评论
旧站的帖子与评论**暂时不会搬过来**。因为直接迁移会丢失旧帖里的图片,迁移方案还在完善中。等完成后我们会发布公告,把大家的帖子完整搬进来,请耐心等待。
在此之前,新内容请直接发在新站。
## 常见问题
**Q:需要重新注册吗?**
A:不需要,用旧站用户名和密码直接登录。
**Q:密码忘了怎么办?**
A:联系管理员,核对身份后协助重置。
**Q:旧帖会丢失吗?**
A:不会。帖子与评论等迁移方案完善后会完整搬入,届时另行公告。
---
如有问题或建议,可通过邮件联系:aarbbs@88.com
—— 姜十三论坛 团队

Some files were not shown because too many files have changed in this diff Show More