Compare commits
44 Commits
4ac4f64f33
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b997528bad | |||
| 426bedda65 | |||
| d4a833f149 | |||
| 3f4d827926 | |||
| 1eb3cc7c86 | |||
| 20067f7640 | |||
| ecbae7c4fa | |||
| b10828c851 | |||
| e7092254ab | |||
| 3d69dae799 | |||
| c3c7390498 | |||
| 2449a27eb5 | |||
| e7185f58fb | |||
| 786f463613 | |||
| 518a4248d0 | |||
| 58d6a45b38 | |||
| 742b79475f | |||
| 99721f562c | |||
| 97adf54d6d | |||
| e65f903362 | |||
| ef6c97106d | |||
| 47efd7921b | |||
| 9a496d3055 | |||
| e9b7401dc9 | |||
| 0b8f6f890d | |||
| 977c52513a | |||
| 9c452afb40 | |||
| fc57ed17d4 | |||
| 2d1dd5d401 | |||
| 1933ea86b4 | |||
| 0014bdb9ff | |||
| 7b5856e0ba | |||
| a2e8242c21 | |||
| ee137b23a6 | |||
| 8bf6ac9e51 | |||
| 2ea9788ec7 | |||
| be729c29a1 | |||
| 584d7fd146 | |||
| 6c9a4414cb | |||
| 864a488ee0 | |||
| fb9724faed | |||
| 58aabb9bf0 | |||
| ae15025b1d | |||
| be8ce04334 |
28
.env.example
28
.env.example
@ -1,6 +1,24 @@
|
|||||||
# 复制为 .env 后填入真实值
|
# ==================================
|
||||||
# .env 会覆盖 config.yaml 中的同名字段
|
# MCE 环境变量配置模板
|
||||||
|
# 复制为 .env 后填入实际值
|
||||||
|
# cp .env.example .env
|
||||||
|
# ==================================
|
||||||
|
|
||||||
DATABASE_PASSWORD=your_password_here
|
# --- 数据库 ---
|
||||||
JWT_SECRET=generate-a-random-64-char-string-here
|
DATABASE_HOST=127.0.0.1
|
||||||
REDIS_PASSWORD=your_redis_password_here
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=metazone
|
||||||
|
DATABASE_PASSWORD=
|
||||||
|
DATABASE_DBNAME=metalab_dev
|
||||||
|
DATABASE_SSLMODE=disable
|
||||||
|
|
||||||
|
# --- Redis(enabled=false 时以下可忽略) ---
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# --- 服务 ---
|
||||||
|
SERVER_PORT=8080
|
||||||
|
SERVER_MODE=debug
|
||||||
|
|||||||
36
.gitea/workflows/ci.yml
Normal file
36
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint + Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
|
||||||
|
restore-keys: ${{ runner.os }}-go-
|
||||||
|
|
||||||
|
- name: Install tools
|
||||||
|
run: |
|
||||||
|
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
|
go install mvdan.cc/gofumpt@latest
|
||||||
|
go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
|
||||||
|
- name: CI (strict)
|
||||||
|
run: bash scripts/ci.sh --strict
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -20,6 +20,12 @@ go.work.sum
|
|||||||
storage/uploads/
|
storage/uploads/
|
||||||
storage/logs/
|
storage/logs/
|
||||||
|
|
||||||
|
# 设计文档(本地使用,不纳入版本控制)
|
||||||
|
_Design/
|
||||||
|
|
||||||
|
# 自用模板(不纳入版本控制)
|
||||||
|
templates/jay-pub/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
41
.golangci.yml
Normal file
41
.golangci.yml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
|
||||||
|
enable:
|
||||||
|
- errcheck # 未处理的 error 返回值
|
||||||
|
- gosec # 安全漏洞模式
|
||||||
|
- govet # go vet 标准检查(含 copylocks)
|
||||||
|
- staticcheck # 大量静态分析规则
|
||||||
|
- ineffassign # 无效赋值
|
||||||
|
- unused # 未使用的变量/常量/函数/类型
|
||||||
|
- revive # 代码风格(doc 注释等)
|
||||||
|
- contextcheck # context.Background() 误用检测
|
||||||
|
- errorlint # errors.Is / errors.As 使用检测
|
||||||
|
- errname # Sentinel error 必须以 Err 为前缀
|
||||||
|
- misspell # 拼写错误
|
||||||
|
|
||||||
|
settings:
|
||||||
|
gosec:
|
||||||
|
excludes: []
|
||||||
|
|
||||||
|
staticcheck:
|
||||||
|
checks: ["all"]
|
||||||
|
|
||||||
|
revive:
|
||||||
|
severity: warning
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
|
- third_party
|
||||||
|
- ".*_test\\.go$"
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofumpt # 比 gofmt 更严格的格式化
|
||||||
|
- goimports # import 自动分组 + 增删
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: true
|
||||||
40
Makefile
Normal file
40
Makefile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
.PHONY: lint fmt check test
|
||||||
|
|
||||||
|
GOBIN := $(HOME)/go/bin
|
||||||
|
BIN_GOFUMPT := $(GOBIN)/gofumpt
|
||||||
|
BIN_GOIMPORTS := $(GOBIN)/goimports
|
||||||
|
BIN_LINT := $(GOBIN)/golangci-lint
|
||||||
|
|
||||||
|
# === 代码格式化 ===
|
||||||
|
fmt:
|
||||||
|
@echo "==> gofumpt..."
|
||||||
|
@$(BIN_GOFUMPT) -l -w internal/ cmd/
|
||||||
|
@echo "==> goimports..."
|
||||||
|
@$(BIN_GOIMPORTS) -w internal/ cmd/
|
||||||
|
|
||||||
|
# === 格式检查(CI 用,只读不改) ===
|
||||||
|
fmt-check:
|
||||||
|
@echo "==> gofumpt check..."
|
||||||
|
@test -z "$$($(BIN_GOFUMPT) -l internal/ cmd/)" || (echo "gofumpt: 以下文件格式不正确:" && $(BIN_GOFUMPT) -l internal/ cmd/ && exit 1)
|
||||||
|
@echo "==> goimports check..."
|
||||||
|
@test -z "$$($(BIN_GOIMPORTS) -l internal/ cmd/)" || (echo "goimports: 以下文件 import 不规范:" && $(BIN_GOIMPORTS) -l internal/ cmd/ && exit 1)
|
||||||
|
|
||||||
|
# === 静态分析(报告模式,不阻断) ===
|
||||||
|
lint:
|
||||||
|
@echo "==> golangci-lint (report only)..."
|
||||||
|
@$(BIN_LINT) run ./... || true
|
||||||
|
|
||||||
|
# === 静态分析(阻断模式,CI 用) ===
|
||||||
|
lint-strict:
|
||||||
|
@echo "==> golangci-lint (strict)..."
|
||||||
|
@$(BIN_LINT) run ./...
|
||||||
|
|
||||||
|
# === 测试 ===
|
||||||
|
test:
|
||||||
|
@echo "==> go test..."
|
||||||
|
@go test -v -race ./...
|
||||||
|
|
||||||
|
# === CI 完整流程(报告模式) ===
|
||||||
|
ci:
|
||||||
|
@$(MAKE) fmt-check
|
||||||
|
@$(MAKE) lint
|
||||||
118
README.md
118
README.md
@ -3,23 +3,29 @@
|
|||||||
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
|
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
|
||||||
<img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31">
|
<img src="https://img.shields.io/badge/GORM-1.31-7A6F5D?style=flat-square" alt="GORM 1.31">
|
||||||
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
<img src="https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||||
|
<img src="https://img.shields.io/badge/Redis-7-DC382D?style=flat-square&logo=redis&logoColor=white" alt="Redis">
|
||||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0">
|
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL 3.0">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">MetaLab</h1>
|
<h1 align="center">MetaZone Community Engine</h1>
|
||||||
|
|
||||||
<p align="center"><em>下一代开源技术社区——为构建者准备的纯粹空间</em></p>
|
<p align="center"><strong>MCE</strong> — 下一代开源社区引擎,为 BBS 3.0 而生</p>
|
||||||
|
|
||||||
|
> **⚠️ 维护状态**
|
||||||
|
> 因个人原因,本项目暂时无法继续活跃维护。现有功能可正常使用,Issues/PR 可能无法及时响应。
|
||||||
|
> 如有兴趣接手或继续开发,欢迎 Fork。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 关于项目
|
## 关于项目
|
||||||
|
|
||||||
MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者提供一个**平等、纯粹、开放**的技术交流空间。
|
MCE 是 **MetaZone Community Engine** 的缩写,是 MetaZone 品牌下的社区平台核心引擎。构建于 **MetaGenesis** 体系之上,定位为通用型社区/BBS 平台,面向下一个十年。
|
||||||
|
|
||||||
**核心理念:**
|
**核心理念:**
|
||||||
- 🎯 **专注技术**——代码、架构、逻辑与可验证的事实
|
- 🌊 **流式社区**——告别传统板块制,内容以信息流自然流动
|
||||||
- 🤝 **平等交流**——没有特权,每个成员权利平等
|
- 🤝 **平等参与**——去中心化讨论,每个成员权利平等
|
||||||
- 🔓 **开放共享**——鼓励开源、协作与知识共享
|
- 🧩 **模块可组装**——MetaGenesis 框架层提供通用基础能力,引擎层专注社区体验
|
||||||
|
- 🔓 **开放共享**——AGPL 开源,鼓励二次开发与生态共建
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@ -29,32 +35,33 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
|
|||||||
| Web 框架 | **Gin** | 1.12 |
|
| Web 框架 | **Gin** | 1.12 |
|
||||||
| ORM | **GORM** | 1.31 |
|
| ORM | **GORM** | 1.31 |
|
||||||
| 数据库 | **PostgreSQL** | 16+ |
|
| 数据库 | **PostgreSQL** | 16+ |
|
||||||
| 认证 | **JWT** (HS256) | v5 |
|
| 缓存 | **Redis** | 7+ |
|
||||||
| 密码加密 | **bcrypt** (cost=12) | — |
|
| 密码加密 | **bcrypt** (cost=12) | — |
|
||||||
| 编辑器 | **Vditor** | 3.11 |
|
| 编辑器 | **Tiptap** (WYSIWYG) | — |
|
||||||
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
|
| 前端 | **SSR 模板** (Gin HTML) + 原生 JS/CSS | — |
|
||||||
| 架构 | 分层六边形架构 | — |
|
| 架构 | 分层架构 (Controller → Service → Repository) | — |
|
||||||
|
|
||||||
## 当前功能
|
## 当前功能
|
||||||
|
|
||||||
- ✅ **用户系统**——注册、登录、登出
|
- ✅ **用户系统**——注册、登录、登出,会话管理
|
||||||
- ✅ **双 Token 认证**——Access Token(15min)+ Refresh Token(7d session / 30d "记住我"),token_version 实时吊销
|
- ✅ **安全认证**——CSRF 保护、速率限制(账户 + IP)、安全响应头
|
||||||
- ✅ **CSRF 保护**——全站 CSRF Token,自动注入 state-changing 请求
|
- ✅ **管理后台**——用户管理、角色权限控制、审核面板
|
||||||
- ✅ **速率限制**——API 调用频率控制(账户 + IP 双维度)
|
|
||||||
- ✅ **安全头**——Content-Security-Policy 等安全响应头 + 登录防时序攻击
|
|
||||||
- ✅ **管理后台**——用户管理、角色权限控制
|
|
||||||
- ✅ **账号注销**——用户自助注销(7 天冷却期,登录二次确认恢复),站长禁止自删
|
- ✅ **账号注销**——用户自助注销(7 天冷却期,登录二次确认恢复),站长禁止自删
|
||||||
- ✅ **密码修改**——修改后强制所有设备退登
|
- ✅ **密码修改**——修改后强制所有设备退登
|
||||||
- ✅ **IP 审计**——记录注册 IP、最后登录 IP 及时间
|
- ✅ **IP 审计**——记录注册 IP、最后登录 IP 及时间
|
||||||
- ✅ **用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切)
|
- ✅ **用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切)
|
||||||
- ✅ **头像处理**——JPEG/PNG/WebP 上传,方形裁切,512×512 WebP 编码 ≤100KB
|
- ✅ **头像处理**——JPEG/PNG/WebP 上传,方形裁切,512×512 WebP 编码
|
||||||
- ✅ **消息中心**——审核通知推送,SSR 消息页面,单条/全部标为已读,未读数角标(1/66/99+)
|
- ✅ **消息中心**——通知推送,SSR 消息页面,已读/未读管理,未读数角标
|
||||||
- ✅ **帖子系统**——Markdown 发布/编辑/删除,Vditor 所见即所得编辑器,图片粘贴/拖拽上传
|
- ✅ **@提及系统**——@用户搜索、通知推送、通知点击定位高亮
|
||||||
|
- ✅ **用户空间**——个人主页 `/space/:uid`,关注/取关,关注列表
|
||||||
|
- ✅ **帖子系统**——Markdown 发布/编辑/删除,Tiptap 所见即所得编辑器
|
||||||
- ✅ **代码高亮**——highlight.js 60+ 主题,支持 30+ 编程语言
|
- ✅ **代码高亮**——highlight.js 60+ 主题,支持 30+ 编程语言
|
||||||
- ✅ **Shortcode 卡片**——`[zone:event:ID]` 活动/游戏/投票/资源等可扩展卡片
|
- ✅ **Shortcode 卡片**——`[zone:event:ID]` 活动/游戏/投票/资源等可扩展卡片
|
||||||
- ✅ **草稿自动保存**——localStorage 草稿,每 2 秒自动保存
|
- ✅ **草稿自动保存**——localStorage 草稿,自动保存
|
||||||
- ✅ **审核工作流**——帖子 draft → pending → approved/rejected,管理员审核面板
|
- ✅ **审核工作流**——帖子 draft → pending → approved/rejected
|
||||||
- ✅ **多主题支持**——可切换的前端主题系统
|
- ✅ **多主题支持**——可切换的前端主题系统
|
||||||
|
- ✅ **赋值(赋能)系统**——社区互动激励,赋能溢出截断 + 防自赋能
|
||||||
|
- ✅ **收藏功能**——文章收藏,收藏夹管理
|
||||||
|
|
||||||
## 计划中
|
## 计划中
|
||||||
|
|
||||||
@ -63,39 +70,82 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
|
|||||||
- 🔲 邮箱验证
|
- 🔲 邮箱验证
|
||||||
- 🔲 全文搜索
|
- 🔲 全文搜索
|
||||||
- 🔲 回收站(用户数据彻底清理)
|
- 🔲 回收站(用户数据彻底清理)
|
||||||
|
- 🔲 Redis 会话 & 缓存层深度集成
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
| 依赖 | 最低版本 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Go | 1.26 | 编译运行 |
|
||||||
|
| PostgreSQL | 16 | 主数据库 |
|
||||||
|
| Redis | 7 | 会话存储(可选,关闭后使用内存存储) |
|
||||||
|
|
||||||
|
### 1. 克隆并配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库
|
git clone git@git.metazone.cc:MetaZone/mce.git
|
||||||
git clone git@git.metazone.cc:MetaZone/MetaLab.git
|
cd mce
|
||||||
cd MetaLab
|
```
|
||||||
|
|
||||||
# 创建 .env 文件
|
复制环境变量模板并填入实际值:
|
||||||
cat > .env << EOF
|
|
||||||
DATABASE_PASSWORD=your_db_password
|
|
||||||
JWT_SECRET=your_jwt_secret
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 确保 PostgreSQL 运行中
|
```bash
|
||||||
# 默认连接: 127.0.0.1:5432, 数据库: metalab_dev
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
# 运行
|
编辑 `.env`,至少填入数据库密码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_HOST=127.0.0.1
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=metazone
|
||||||
|
DATABASE_PASSWORD=your_password
|
||||||
|
DATABASE_DBNAME=metalab_dev
|
||||||
|
|
||||||
|
REDIS_ENABLED=true # 可选,false 则使用内存存储
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
> 所有连接信息统一在 `.env` 管理。`config.yaml` 保留默认值兜底,通常无需修改。
|
||||||
|
|
||||||
|
### 2. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 按序执行迁移 SQL
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/001_uid_to_id.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/002_announcements.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/003_post_attributes.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/004_categories_tags.sql
|
||||||
|
psql -U metazone -d metalab_dev -f docs/migrations/005_scheduled_posts.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
首次部署后注册的第一个用户自动获得站长(owner)权限。
|
||||||
|
|
||||||
|
### 3. 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
go run cmd/server/main.go
|
go run cmd/server/main.go
|
||||||
|
# 或编译后运行
|
||||||
|
go build -o mce cmd/server/main.go && ./mce
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问 `http://localhost:8080`。
|
启动后访问 `http://localhost:8080`。
|
||||||
|
|
||||||
|
> 生产环境部署详见 [`docs/deployment.md`](docs/deployment.md)。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
MetaLab/
|
mce/
|
||||||
├── cmd/server/ # 入口:初始化 → 启动
|
├── cmd/server/ # 入口:初始化 → 启动
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # 配置加载(YAML + .env)
|
│ ├── config/ # 配置加载(YAML + .env)
|
||||||
│ ├── router/ # 路由注册
|
│ ├── router/ # 路由注册
|
||||||
│ ├── controller/ # 控制器(参数绑定 → 调服务 → 返回)
|
│ ├── controller/ # 控制器(参数绑定 → 调服务 → 返回)
|
||||||
│ │ └── admin/ # 后台控制器
|
|
||||||
│ ├── service/ # 业务逻辑层
|
│ ├── service/ # 业务逻辑层
|
||||||
│ ├── repository/ # 数据访问层(DAO)
|
│ ├── repository/ # 数据访问层(DAO)
|
||||||
│ ├── model/ # 数据模型 + DTO
|
│ ├── model/ # 数据模型 + DTO
|
||||||
@ -119,5 +169,5 @@ MetaLab/
|
|||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>© 2024-2026 MetaZone | Built with ❤️ by <a href="https://metazone.cc">MetaZone</a> </sub>
|
<sub>Built on <a href="#">MetaGenesis</a> | © 2024-2026 <a href="https://metazone.cc">MetaZone</a></sub>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/middleware"
|
||||||
"metazone.cc/metalab/internal/router"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/theme"
|
"metazone.cc/mce/internal/repository"
|
||||||
|
"metazone.cc/mce/internal/router"
|
||||||
|
"metazone.cc/mce/internal/scheduler"
|
||||||
|
"metazone.cc/mce/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@ -19,6 +28,16 @@ func main() {
|
|||||||
// 配置
|
// 配置
|
||||||
cfg := config.Load("config.yaml")
|
cfg := config.Load("config.yaml")
|
||||||
|
|
||||||
|
// 设置时区
|
||||||
|
if cfg.Server.Timezone != "" {
|
||||||
|
loc, err := time.LoadLocation(cfg.Server.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("警告:时区 %s 无效,使用系统时区: %v", cfg.Server.Timezone, err)
|
||||||
|
} else {
|
||||||
|
time.Local = loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 从配置初始化角色系统(OCP:新增角色无需修改源代码)
|
// 从配置初始化角色系统(OCP:新增角色无需修改源代码)
|
||||||
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
|
model.InitRoles(cfg.Roles.Levels, cfg.Roles.Names, cfg.Roles.Permissions.OperableRoles)
|
||||||
|
|
||||||
@ -27,7 +46,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}, &model.Announcement{}, &model.Category{}, &model.Tag{}, &model.PostTag{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,26 +74,49 @@ func main() {
|
|||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
|
// 站点设置管理器(DB 持久化 + 内存缓存 — 必须在模板加载前初始化)
|
||||||
common.ComputeAssetHashes("templates/MetaLab-2026")
|
siteSettings, err := config.NewSiteSettings(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("初始化站点设置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取当前主题名称
|
||||||
|
themeName := siteSettings.ThemeName()
|
||||||
|
log.Printf("当前主题: %s", themeName)
|
||||||
|
|
||||||
|
// 计算静态资源哈希
|
||||||
|
common.ComputeAssetHashes("templates/" + themeName)
|
||||||
|
|
||||||
// 模板加载(前端主题 + 管理后台)
|
// 模板加载(前端主题 + 管理后台)
|
||||||
tmpl, err := theme.LoadTemplates(
|
tmpl, err := loadThemeTemplates(themeName)
|
||||||
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
|
|
||||||
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("加载模板失败: " + err.Error())
|
log.Fatalf("加载模板失败: %v", err)
|
||||||
}
|
}
|
||||||
r.SetHTMLTemplate(tmpl)
|
r.SetHTMLTemplate(tmpl)
|
||||||
|
|
||||||
// 静态资源
|
// 静态资源 — 前台使用动态主题目录
|
||||||
r.Static("/static", "./templates/MetaLab-2026/static")
|
r.GET("/static/*filepath", func(c *gin.Context) {
|
||||||
|
serveDynamicStatic(c, "templates", siteSettings, c.Param("filepath"))
|
||||||
|
})
|
||||||
r.Static("/admin/static", "./templates/admin/static")
|
r.Static("/admin/static", "./templates/admin/static")
|
||||||
r.Static("/shared/static", "./templates/shared/static")
|
r.Static("/shared/static", "./templates/shared/static")
|
||||||
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||||
r.Static("/uploads/posts", "./storage/uploads/posts")
|
r.Static("/uploads/posts", "./storage/uploads/posts")
|
||||||
|
|
||||||
|
// 主题重载函数(切换主题后实时生效)
|
||||||
|
themeReloader := func(newTheme string) error {
|
||||||
|
log.Printf("切换主题: %s", newTheme)
|
||||||
|
// 更新静态资源哈希
|
||||||
|
common.ComputeAssetHashes("templates/" + newTheme)
|
||||||
|
// 重新加载模板
|
||||||
|
newTmpl, err := loadThemeTemplates(newTheme)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.SetHTMLTemplate(newTmpl)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
|
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
|
||||||
var redisClient *redis.Client
|
var redisClient *redis.Client
|
||||||
if cfg.Redis.Enabled {
|
if cfg.Redis.Enabled {
|
||||||
@ -89,16 +131,59 @@ func main() {
|
|||||||
log.Println("Redis 未启用,使用内存存储")
|
log.Println("Redis 未启用,使用内存存储")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 站点设置管理器(DB 持久化 + 内存缓存)
|
// 启动数据库健康检测(每 5s ping,失败自动降级)
|
||||||
siteSettings, err := config.NewSiteSettings(db)
|
middleware.StartDBHealthCheck(db)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("初始化站点设置失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路由注册
|
// 路由注册
|
||||||
router.Setup(r, db, cfg, siteSettings, redisClient)
|
router.Setup(r, db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
|
// 注入主题重载回调到站点设置 Controller
|
||||||
|
router.InjectThemeReloader(themeReloader)
|
||||||
|
|
||||||
|
// 定时发布调度器
|
||||||
|
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
|
||||||
|
|
||||||
// 启动
|
// 启动
|
||||||
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
||||||
r.Run("0.0.0.0:" + cfg.Server.Port)
|
if err := r.Run("0.0.0.0:" + cfg.Server.Port); err != nil {
|
||||||
|
log.Fatalf("服务启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadThemeTemplates 加载指定主题的模板(前台 + 管理后台)
|
||||||
|
func loadThemeTemplates(themeName string) (*template.Template, error) {
|
||||||
|
return theme.LoadTemplates(
|
||||||
|
theme.TemplateRoot{Dir: "templates/" + themeName + "/html", Prefix: ""},
|
||||||
|
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveDynamicStatic 根据当前站点主题动态提供静态资源
|
||||||
|
func serveDynamicStatic(c *gin.Context, baseDir string, ss *config.SiteSettings, file string) {
|
||||||
|
themeName := ss.ThemeName()
|
||||||
|
root := filepath.Join(baseDir, themeName, "static")
|
||||||
|
|
||||||
|
// 清理路径并拼接
|
||||||
|
cleanFile := filepath.Clean(file)
|
||||||
|
fullPath := filepath.Join(root, cleanFile)
|
||||||
|
|
||||||
|
// 安全检查:防止路径遍历逃逸出主题目录
|
||||||
|
absRoot, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
absPath, err := filepath.Abs(fullPath)
|
||||||
|
if err != nil || !strings.HasPrefix(absPath, absRoot+string(os.PathSeparator)) && absPath != absRoot {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeFile(c.Writer, c.Request, fullPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
mode: debug # debug | release | test
|
mode: debug # debug | release | test
|
||||||
|
cookie_secure: false # Cookie Secure 标志(生产环境应设为 true,部分反向代理环境下可能需要灵活控制)
|
||||||
|
timezone: Asia/Shanghai # 时区(影响 time.Now() 和模板时间格式化)
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
|
|||||||
@ -1,426 +0,0 @@
|
|||||||
# MetaLab 项目全量代码审计报告
|
|
||||||
|
|
||||||
**审计日期**: 2025-05-31
|
|
||||||
**审计范围**: `lab.metazone.cc-GO/` 全部 Go 源码、模板、配置
|
|
||||||
**项目状态**: 未发布,无需考虑旧版兼容
|
|
||||||
**审查标准**: DRY/KISS/YAGNI/LoD/SOLID + 最佳实践
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 问题清单
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #1: 【严重】CSP 安全头引用已废弃的 Tiptap CDN(esm.sh)
|
|
||||||
|
|
||||||
**类型**: 死代码 / 残留配置
|
|
||||||
**位置**: `internal/middleware/security.go:12-21`
|
|
||||||
**违反原则**: KISS(引用了不存在的依赖)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + cdnjs (highlight.js)
|
|
||||||
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
|
||||||
"style-src 'self' 'unsafe-inline' https://esm.sh https://cdnjs.cloudflare.com; "+
|
|
||||||
"connect-src 'self' https://esm.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 项目已迁移到 Vditor 编辑器,但 CSP 头仍保留 Tiptap 时代的 esm.sh CDN 白名单。三个指令(script-src、style-src、connect-src)都包含未使用的 `https://esm.sh`。
|
|
||||||
|
|
||||||
**风险**:
|
|
||||||
- 扩大了不必要的 CSP 白名单,引入额外信任域
|
|
||||||
- connect-src 允许到 esm.sh 的连接,可能泄露页面信息
|
|
||||||
|
|
||||||
**建议**: 移除所有 `https://esm.sh` 引用,highlight.js 主题已本地化为 73 个静态文件(`static/vditor/dist/js/highlight.js/styles/`),CSP 可完全收紧为 `'self'`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #2: 【严重】Login() 中封禁状态检查缺少防时序攻击保护
|
|
||||||
|
|
||||||
**类型**: 安全缺陷
|
|
||||||
**位置**: `internal/service/auth_service.go:151-154`
|
|
||||||
**违反原则**: 最佳安全实践
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 封禁
|
|
||||||
if user.Status == model.StatusBanned {
|
|
||||||
return nil, common.ErrUserBanned // ← 没有 bcrypt dummy hash 比对
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 其他分支(维护模式、用户不存在、密码错误、StatusLocked)都有 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)` 防时序攻击,唯独 StatusBanned 分支缺失。攻击者可以通过响应时间差异判断被封禁的账号是否存在。
|
|
||||||
|
|
||||||
**建议**: 在返回 `ErrUserBanned` 前增加 dummy hash 比对:
|
|
||||||
```go
|
|
||||||
if user.Status == model.StatusBanned {
|
|
||||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
|
||||||
return nil, common.ErrUserBanned
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #3: 【严重】自定义 constantTimeEq 不如 crypto/subtle 安全
|
|
||||||
|
|
||||||
**类型**: 安全缺陷 / 最佳实践
|
|
||||||
**位置**: `internal/middleware/csrf_token.go:44-53`(被 `csrf.go:47` 调用)
|
|
||||||
**违反原则**: 安全最佳实践
|
|
||||||
|
|
||||||
```go
|
|
||||||
// constantTimeEq 恒定时间字符串比较(防时序攻击)
|
|
||||||
func constantTimeEq(a, b string) bool {
|
|
||||||
if len(a) != len(b) { // ← 长度不等时提前返回,泄露长度信息
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var result byte
|
|
||||||
for i := 0; i < len(a); i++ {
|
|
||||||
result |= a[i] ^ b[i]
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
1. `constantTimeEq` 在同包内被 `csrf.go:47` 调用,**非死代码**
|
|
||||||
2. 但其手写实现存在两个安全隐患:
|
|
||||||
- **长度提前返回泄露信息**:`len(a) != len(b)` 时立即返回 false,攻击者可通过响应时间判断 CSRF token 长度
|
|
||||||
- **无编译器优化防护**:`crypto/subtle.ConstantTimeCompare` 内部使用了特殊的编译器屏障防止被优化,手写版本可能被 Go 编译器优化掉 XOR 结果检查
|
|
||||||
3. 函数定义在 `csrf_token.go`(token 生成文件)而非 `csrf.go`(校验文件),逻辑归属不当
|
|
||||||
|
|
||||||
**建议**: 删除 `constantTimeEq`,改用 `crypto/subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1`。这也是 Go 官方推荐的做法。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #4: 【高】Admin 控制器锁定操作消息显示错误
|
|
||||||
|
|
||||||
**类型**: Bug
|
|
||||||
**位置**: `internal/controller/admin/admin_controller.go:93-96`
|
|
||||||
**违反原则**: 无(纯 bug)
|
|
||||||
|
|
||||||
```go
|
|
||||||
action := "封禁"
|
|
||||||
if req.Status == model.StatusActive {
|
|
||||||
action = "解封"
|
|
||||||
} else if req.Status == model.StatusLocked {
|
|
||||||
action = "已删除" // ← BUG: 应该是 "已锁定"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 当管理员执行锁定操作时,成功提示消息显示"已删除成功",而不是"已锁定成功"。Locked 与 Deleted 是两个完全不同的状态。
|
|
||||||
|
|
||||||
**建议**: 改为 `action = "已锁定"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #5: 【高】Login() 中密码验证方式不一致
|
|
||||||
|
|
||||||
**类型**: 代码一致性问题
|
|
||||||
**位置**: `internal/service/auth_service.go:138,152`
|
|
||||||
**违反原则**: KISS(同一逻辑用了两种实现)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// StatusDeleted 分支:
|
|
||||||
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
|
|
||||||
|
|
||||||
// StatusBanned 之后的主路径:
|
|
||||||
if !common.CheckPassword(req.Password, user.PasswordHash) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
而 `CheckPassword` 内部只是封装了单行:
|
|
||||||
```go
|
|
||||||
func CheckPassword(password, hash string) bool {
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 虽然功能上没有 bug,但在同一函数中既有 `common.CheckPassword()` 调用,也有其他分支使用 `_ = bcrypt.CompareHashAndPassword(dummyHash, ...)`(直接调用 bcrypt)。风格不统一,且 `CheckPassword` 的封装价值极低(仅包装一行标准库调用,不如直接使用 bcrypt 调用更直观)。
|
|
||||||
|
|
||||||
**建议**:
|
|
||||||
- 选项A: 删除 `common.CheckPassword`,统一使用 `bcrypt.CompareHashAndPassword`
|
|
||||||
- 选项B: 在 `CheckPassword` 中增加防时序的一致性包装,统一入口
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #6: 【中】大量空模板目录(YAGNI 违规)
|
|
||||||
|
|
||||||
**类型**: YAGNI 违规
|
|
||||||
**位置**:
|
|
||||||
- `templates/MetaLab-2026/html/post/`(空)
|
|
||||||
- `templates/MetaLab-2026/html/comment/`(空)
|
|
||||||
- `templates/MetaLab-2026/html/partials/`(空)
|
|
||||||
- `templates/MetaLab-2026/html/search/`(空)
|
|
||||||
- `templates/MetaLab-2026/html/error/`(空)
|
|
||||||
- `templates/MetaLab-2026/html/admin/`(空)
|
|
||||||
- `templates/system/email/`(空)
|
|
||||||
|
|
||||||
**问题**: 7 个空目录,标注为"预留"。项目尚未发布,这些目录的创建时机应该和实际功能开发同步,而非提前占位。
|
|
||||||
|
|
||||||
**建议**: 删除所有空目录。需要时随功能一起创建。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #7: 【中】Shortcode 预留类型(poll/resource)无后端实现
|
|
||||||
|
|
||||||
**类型**: YAGNI 违规
|
|
||||||
**位置**: `internal/model/shortcode.go:37-38`, `internal/service/shortcode_service.go:126-137`
|
|
||||||
**违反原则**: YAGNI
|
|
||||||
|
|
||||||
```go
|
|
||||||
ShortcodePoll ShortcodeType = "poll" // ※预留(后端API未实现)
|
|
||||||
ShortcodeResource ShortcodeType = "resource" // ※预留(后端API未实现)
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: poll 和 resource 两个 shortcode 类型的后端 API 未实现,前端也未实现(shortcode.js 中可能也未实现对应渲染),但代码中已注册了完整的解析和占位 HTML 生成逻辑。用户实际上可以使用 `[zone:poll:xxx]` 语法,但会得到一个永远"加载中..."的卡片。
|
|
||||||
|
|
||||||
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。前端渲染逻辑将随 API 同步实现。
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #8: 【中】redis_store.go 全注释的"预留实现"
|
|
||||||
|
|
||||||
**类型**: YAGNI / 死代码
|
|
||||||
**位置**: `internal/session/redis_store.go`
|
|
||||||
**违反原则**: YAGNI
|
|
||||||
|
|
||||||
**问题**: 整个文件是注释掉的代码,没有实际可执行逻辑。如果未来需要 Redis 支持,到时再创建即可。
|
|
||||||
|
|
||||||
**状态**: 已排期开发,保留(不删除)。首次审计时误删,已恢复。Redis 支持将在后续迭代中实现。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #9: 【中】tokenCtrl 是 authCtrl 的无意义别名
|
|
||||||
|
|
||||||
**类型**: 不必要的字段重复
|
|
||||||
**位置**: `internal/router/deps_core.go:30`, `internal/router/deps_extra.go:54`
|
|
||||||
**违反原则**: KISS
|
|
||||||
|
|
||||||
```go
|
|
||||||
// deps_core.go
|
|
||||||
type dependencies struct {
|
|
||||||
// ...
|
|
||||||
tokenCtrl *controller.AuthController // ← 与 authCtrl 类型完全相同
|
|
||||||
}
|
|
||||||
|
|
||||||
// deps_extra.go
|
|
||||||
return &dependencies{
|
|
||||||
// ...
|
|
||||||
tokenCtrl: authCtrl, // ← 赋的是同一个对象
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: `tokenCtrl` 和 `authCtrl` 指向同一个 `*controller.AuthController` 实例,`tokenCtrl` 仅在 `api.go` 的路由中使用(`d.tokenCtrl.CheckEmail/Register/Login/...`)。这个别名不带来任何好处,反而增加理解成本。
|
|
||||||
|
|
||||||
**建议**: 删除 `tokenCtrl` 字段,`api.go` 中直接使用 `d.authCtrl`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #10: 【中】RateLimiter.check() 存在竞态条件
|
|
||||||
|
|
||||||
**类型**: 并发缺陷
|
|
||||||
**位置**: `internal/middleware/ratelimit_core.go:34-81`
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (rl *RateLimiter) check(...) (RateLimitResult, func()) {
|
|
||||||
rl.mu.Lock()
|
|
||||||
defer rl.mu.Unlock()
|
|
||||||
// ... 读取 state ...
|
|
||||||
|
|
||||||
recordFail := func() {
|
|
||||||
rl.mu.Lock() // ← 重新获取锁
|
|
||||||
defer rl.mu.Unlock()
|
|
||||||
// ... 修改 state ...
|
|
||||||
}
|
|
||||||
return RateLimitResult{Blocked: false}, recordFail
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: `check()` 持锁检查后释放锁,返回的 `recordFail` 闭包在**锁外**执行,重新获取锁后再修改状态。
|
|
||||||
|
|
||||||
**修复方案**: 将 `check()` + `recordFail` 闭包模式重构为 `try()` 原子操作模式——在持锁状态下一次性完成检查+递增,消除竞态窗口。API 改为 `AllowAccount() RateLimitResult` / `AllowIP() RateLimitResult`(不再返回闭包)。调用方在失败时不再需要显式调用 `recordFail()`,成功时仍调用 `Clear()` 清除计数。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #11: 【低】audit_service.go review() 注释编号跳跃
|
|
||||||
|
|
||||||
**类型**: 文档瑕疵
|
|
||||||
**位置**: `internal/service/audit_service.go:152-166`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 3. 查审核人信息
|
|
||||||
reviewer, err := s.userRepo.FindByID(reviewerID)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// 5. 标记审核结果 ← 跳过了 4
|
|
||||||
submission.ReviewedBy = &reviewerID
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 注释编号从"3."直接跳到"5.",缺少"4."。
|
|
||||||
|
|
||||||
**建议**: 修正编号为连续递增。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #12: 【低】编译产物未纳入 .gitignore(误报,实际不存在)
|
|
||||||
|
|
||||||
**类型**: 仓库整洁性
|
|
||||||
**位置**: `server`(根目录), `cmd/server/server`
|
|
||||||
**状态**: 误报。经核实,`.gitignore` 已配置 `server` 规则且从未被 git 跟踪,`git rm --cached` 实际为空操作。此项已从修复表中移除。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #13: 【低】项目零测试覆盖
|
|
||||||
|
|
||||||
**类型**: 质量保障缺失
|
|
||||||
**位置**: 整个项目(`*_test.go` 搜索结果: 0)
|
|
||||||
|
|
||||||
**问题**: 项目没有任何单元测试或集成测试。对于包含认证、权限、审核、数据持久化等复杂逻辑的系统,零测试意味着每次重构和修改都有回归风险。
|
|
||||||
|
|
||||||
**建议**: 至少为核心模块添加测试:
|
|
||||||
1. `model/user.go` - HasMinRole/CanOperateRole(纯函数,易测)
|
|
||||||
2. `service/auth_service.go` - 注册/登录/密码验证逻辑
|
|
||||||
3. `service/admin_service.go` - checkAndOperate 权限矩阵
|
|
||||||
4. `middleware/ratelimit_core.go` - 限流逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #14: 【低】全项目使用 log.Printf 无结构化日志
|
|
||||||
|
|
||||||
**类型**: 可维护性 / 可观测性
|
|
||||||
**位置**: 10 个文件,约 17 处 `log.Printf` 调用
|
|
||||||
|
|
||||||
**问题**: 项目大量使用标准库 `log.Printf`,无日志级别、无结构化字段、无上下文追踪。在生产环境中排查问题困难,无法按级别过滤日志。
|
|
||||||
|
|
||||||
**建议**: 引入轻量结构化日志库(如 `slog`,Go 1.21+ 标准库),按级别区分 Info/Warn/Error,关键路径添加 trace/request ID。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #15: 【低】err != nil 返回时上下文信息丢失
|
|
||||||
|
|
||||||
**类型**: 可调试性
|
|
||||||
**位置**: 多处,例如 `internal/repository/user_repo.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (r *UserRepo) Create(user *model.User) error {
|
|
||||||
return r.db.Create(user).Error // ← 无上下文
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 数据库操作失败时,调用方只知道"出错了",无法快速定位是哪个操作、哪个实体、哪个 ID 导致的失败。
|
|
||||||
|
|
||||||
**建议**: 使用 `fmt.Errorf("创建用户失败: %w", err)` 包装错误,在保持错误链的同时添加操作上下文。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #16: 【低】common.CheckPassword 封装价值极低
|
|
||||||
|
|
||||||
**类型**: KISS 违规
|
|
||||||
**位置**: `internal/common/crypto.go:12-15`
|
|
||||||
|
|
||||||
```go
|
|
||||||
func CheckPassword(password, hash string) bool {
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 仅包装一行标准库调用,无额外逻辑。与同一文件中封装了 bcrypt 成本参数的 `HashPassword` 不同,`CheckPassword` 没有提供抽象价值。反而因为隐藏了 `bcrypt.CompareHashAndPassword` 的调用,在需要 `dummyHash` 比对时(如 auth_service.go 的时序攻击防护)不得不绕过它直接调用 bcrypt。
|
|
||||||
|
|
||||||
**建议**:
|
|
||||||
- 如果保留 `CheckPassword`,将 dummy hash 比对也内置进去
|
|
||||||
- 或者删除此函数,直接在各处显式调用 `bcrypt.CompareHashAndPassword`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 #17: 【低】deps_core.go 中文注释错别字
|
|
||||||
|
|
||||||
**类型**: 文档瑕疵
|
|
||||||
**位置**: `internal/router/deps_core.go:45`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 启动后台过清理 goroutine
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: "过清理"应为"过期清理",少了一个"期"字。
|
|
||||||
|
|
||||||
**建议**: 修正为 `启动后台过期清理 goroutine`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 汇总统计
|
|
||||||
|
|
||||||
| 严重程度 | 数量 | 问题编号 |
|
|
||||||
|---------|------|---------|
|
|
||||||
| 严重 | 3 | #1, #2, #3 |
|
|
||||||
| 高 | 2 | #4, #5 |
|
|
||||||
| 中 | 5 | #6, #7, #8, #9, #10 |
|
|
||||||
| 低 | 7 | #11, #12, #13, #14, #15, #16, #17 |
|
|
||||||
|
|
||||||
**总计: 17 个问题**
|
|
||||||
|
|
||||||
### 按原则分类
|
|
||||||
|
|
||||||
| 原则 | 问题编号 |
|
|
||||||
|------------|---------|
|
|
||||||
| 安全 | #1, #2, #3 |
|
|
||||||
| YAGNI | #6, #7, #8 |
|
|
||||||
| KISS | #5, #9, #16 |
|
|
||||||
| Bug | #4, #10 |
|
|
||||||
| 质量/可维护性 | #12, #13, #14, #15 |
|
|
||||||
| 文档 | #11, #17 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 整体评价
|
|
||||||
|
|
||||||
项目的分层架构设计合理,严格遵循单向依赖(router→controller→service→repository→model),接口隔离原则(ISP)执行到位,每层都通过最小接口依赖下层。依赖注入清晰,无循环依赖。
|
|
||||||
|
|
||||||
主要问题集中在三个方面:
|
|
||||||
1. **安全防护需加强** - CSP 配置残留、时序攻击防护不完整
|
|
||||||
2. **代码清理不及时** - 存在死代码、预留目录、未使用函数
|
|
||||||
3. **工程基础设施薄弱** - 零测试、无结构化日志、错误上下文丢失
|
|
||||||
|
|
||||||
建议优先处理严重级别问题(#1~#3),然后按批次逐步处理其余问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 修复记录
|
|
||||||
|
|
||||||
**修复日期**: 2025-05-31
|
|
||||||
**执行方式**: 全量修复(commit: `fix: 审计问题全量修复(安全/YAGNI/Bug/代码质量)`)
|
|
||||||
|
|
||||||
### 已修复(13 项)
|
|
||||||
|
|
||||||
| # | 修复内容 | 变更文件 |
|
|
||||||
|---|---------|---------|
|
|
||||||
| 1 | 移除 CSP 中 esm.sh (Tiptap 残留) 和 cdnjs.cloudflare.com(主题已本地化),CSP 全面收紧为 'self' | `middleware/security.go` |
|
|
||||||
| 2 | StatusBanned 分支增加 dummy hash 防时序攻击 | `service/auth_service.go` |
|
|
||||||
| 3 | 删除手写 constantTimeEq,改用 `crypto/subtle.ConstantTimeCompare` | `middleware/csrf_token.go`, `middleware/csrf.go` |
|
|
||||||
| 4 | 锁定操作消息修正"已删除"→"已锁定" | `controller/admin/admin_controller.go` |
|
|
||||||
| 5 | 删除 common.CheckPassword 薄封装,统一改用 bcrypt.CompareHashAndPassword | `common/crypto.go`, `service/auth_service.go` |
|
|
||||||
| 6 | 删除 12 个空预留目录(含第二轮追加 5 个) | `templates/MetaLab-2026/html/{post,comment,partials,search,error,admin,topic,user}`, `templates/{system,system/email}`, `templates/MetaLab-2026/static/{img,vendor}` |
|
|
||||||
| 9 | 删除 tokenCtrl 别名字段,统一使用 authCtrl | `router/deps_core.go`, `router/deps_extra.go`, `router/api.go` |
|
|
||||||
| 10 | 将 check()+recordFail 闭包重构为 try() 原子操作,消除竞态 | `middleware/ratelimit_core.go`, `middleware/ratelimit.go`, `middleware/ratelimit_cleanup.go`, `controller/auth_api_login.go`, `controller/auth_api_register.go` |
|
|
||||||
| 11 | 修正 review() 注释编号 3→5→4 | `service/audit_service.go` |
|
|
||||||
| 17 | 修正"过清理"→"过期清理" | `router/deps_core.go` |
|
|
||||||
|
|
||||||
### 已回滚(误删,已排期开发,保留)
|
|
||||||
|
|
||||||
| # | 回滚内容 | 变更文件 |
|
|
||||||
|---|---------|---------|
|
|
||||||
| 7 | 恢复 poll/resource shortcode 类型(误删) | `model/shortcode.go`, `service/shortcode_service.go` |
|
|
||||||
| 8 | 恢复 redis_store.go 预留实现(误删) | `session/redis_store.go` |
|
|
||||||
|
|
||||||
### 误报(实际不存在)
|
|
||||||
|
|
||||||
| # | 说明 |
|
|
||||||
|---|------|
|
|
||||||
| 12 | 编译产物从未被 git 跟踪,`.gitignore` 已生效,`git rm --cached` 为空操作 |
|
|
||||||
|
|
||||||
### 暂缓修复(3 项)
|
|
||||||
|
|
||||||
| # | 暂缓原因 | 后续计划 |
|
|
||||||
|---|---------|---------|
|
|
||||||
| 13 | 零测试 — 需要建立测试框架、mock 策略,工作量大 | 核心模块优先:hasMinRole、checkAndOperate、RateLimiter |
|
|
||||||
| 14 | 无结构化日志 — 需评估 slog vs zap,全量替换 log.Printf | Go 1.21+ 使用标准库 slog 渐进替换 |
|
|
||||||
| 15 | 错误上下文丢失 — 涉及全部 repository 层,工作量大 | 按文件逐批添加 `fmt.Errorf("...: %w", err)` |
|
|
||||||
@ -1,5 +1,103 @@
|
|||||||
# Go 代码规范
|
# Go 代码规范
|
||||||
|
|
||||||
|
## AI 执行协议
|
||||||
|
|
||||||
|
本文档是 AI 编程助手的**可执行规范**。处理每条用户请求时,AI **MUST** 执行以下流程:
|
||||||
|
|
||||||
|
1. **请求分析**:解析用户意图,识别涉及的代码层次(Controller / Service / Repository / Middleware / 模板 等)。
|
||||||
|
2. **规范对照**:逐条检查请求是否违反本文档中任何 **MUST** 或 **MUST NOT** 规则。
|
||||||
|
3. **违规警告**:若检测到违规,**MUST** 在修改代码前明确警告用户,格式如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ 规范警告:[规则编号/章节] — [违规简述]
|
||||||
|
当前请求:[用户想做什么]
|
||||||
|
违反规则:[引用具体 MUST/MUST NOT 条文]
|
||||||
|
合规做法:[给出替代方案]
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **等待确认**:发出警告后 **MUST NOT** 继续执行修改,直到用户明确指示:
|
||||||
|
- "忽略" / "按我说的做" → 照常执行,但需在代码中加 `// NOTE: 已知违反 code-style.md [规则编号]` 注释
|
||||||
|
- "按合规做法" → 切换为合规方案执行
|
||||||
|
- 修改请求 → 按新请求重新评估
|
||||||
|
|
||||||
|
5. **通过则直接执行**:若无违规,AI 直接按请求执行修改,无需额外确认。
|
||||||
|
|
||||||
|
6. **修改后自检**:每次代码修改完成后,AI **MUST** 运行 `make fmt-check` 和 `make lint`。若产生新告警,**MUST** 立即修复后再次检查,直到零新告警。
|
||||||
|
|
||||||
|
> **判定原则**:每次判定只基于本文档的 MUST/MUST NOT 文本本身,不引入主观解读或"社区惯例"作为额外标准。MAY 规则不触发警告,仅作为可选建议。
|
||||||
|
|
||||||
|
## 代码格式化
|
||||||
|
|
||||||
|
### gofmt(强制)
|
||||||
|
|
||||||
|
所有 `.go` 文件提交前 **MUST** 通过 `gofmt -s -w .` 格式化。CI 中 **MUST** 配置 `gofmt -s -d .` 检查,若输出非空则流水线失败。
|
||||||
|
|
||||||
|
禁止手动调整代码排版——一切以 `gofmt` 输出为准。
|
||||||
|
|
||||||
|
### goimports(强制)
|
||||||
|
|
||||||
|
所有 `.go` 文件提交前 **MUST** 通过 `goimports -w .` 处理 import 语句。`goimports` 自动完成两件事:
|
||||||
|
1. 增删 import 行(用到的加、没用到的删)
|
||||||
|
2. 按标准分组排列 import
|
||||||
|
|
||||||
|
**Import 分组规则(goimports 默认行为,MUST 遵循):**
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
// 第一组:标准库
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
// 第二组:第三方库
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
// 第三组:本项目内部包
|
||||||
|
"metazone.cc/mce/internal/common"
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
三组之间以空行分隔。**MUST NOT** 手动调整分组顺序或插入不属于该组的 import。
|
||||||
|
|
||||||
|
### gofumpt(推荐)
|
||||||
|
|
||||||
|
推荐启用 `gofumpt`(`gofmt` 的超集,更严格),替代基础 `gofmt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofumpt -l -w .
|
||||||
|
```
|
||||||
|
|
||||||
|
`gofumpt` 在 `gofmt` 基础上额外强制执行:字段对齐规则、多余空行清除、`var` 声明块合并等。
|
||||||
|
|
||||||
|
## 自动化检查
|
||||||
|
|
||||||
|
以下检查 **MUST** 在 CI 流水线中执行,且 **MUST** 零告警通过:
|
||||||
|
|
||||||
|
| 检查项 | 工具 | 命令示例 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| 代码格式 | `gofmt` 或 `gofumpt` | `gofmt -s -d .` |
|
||||||
|
| import 管理 | `goimports` | `goimports -l .` |
|
||||||
|
| 静态分析 | `golangci-lint` | `golangci-lint run ./...` |
|
||||||
|
| 编译检查 | `go build` | `go build ./...` |
|
||||||
|
| 竞态检测 | `go test -race` | 在测试阶段执行 |
|
||||||
|
|
||||||
|
`golangci-lint` 至少启用以下 linter(配置写入 `.golangci.yml`):
|
||||||
|
|
||||||
|
| Linter | 检测内容 |
|
||||||
|
|--------|----------|
|
||||||
|
| `errcheck` | 未处理的 error 返回值 |
|
||||||
|
| `gosec` | 安全漏洞模式 |
|
||||||
|
| `revive` | 代码风格(含文件行数/函数行数警告、doc 注释缺失等) |
|
||||||
|
| `contextcheck` | `context.Background()` 在非 init/main/test 代码中的误用 |
|
||||||
|
| `govet` | go vet 标准检查 |
|
||||||
|
| `staticcheck` | 大量静态分析规则 |
|
||||||
|
| `ineffassign` | 无效赋值 |
|
||||||
|
| `unused` | 未使用的变量/常量/函数/类型 |
|
||||||
|
|
||||||
|
> **注意**:`contextcheck` 会直接检测到在 Service/Repository 方法内调用 `context.Background()` 的行为并报错,与本文档 Context 传播规则一致。
|
||||||
|
|
||||||
## 文件大小
|
## 文件大小
|
||||||
|
|
||||||
| 层次 | 单文件行数上限 | 说明 |
|
| 层次 | 单文件行数上限 | 说明 |
|
||||||
@ -11,6 +109,16 @@
|
|||||||
| Middleware | ≤ 60 行 | 单一职责 |
|
| Middleware | ≤ 60 行 | 单一职责 |
|
||||||
| Router | ≤ 60 行 | 只做路由映射 |
|
| Router | ≤ 60 行 | 只做路由映射 |
|
||||||
|
|
||||||
|
> **注意**:行数上限为**警告线**而非硬截断。拆分决策遵循 SRP(一个文件只有一个变更原因):
|
||||||
|
|
||||||
|
| 该拆 | 不该拆 |
|
||||||
|
|------|--------|
|
||||||
|
| 一个文件混了两个及以上业务域(如 `commentService` + `likeService` 塞同一个文件) | 只有单一业务域,但因逻辑复杂产生了大量私有辅助函数 — 保持内聚 |
|
||||||
|
| 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) |
|
||||||
|
| 路由文件按业务域自然分割 | 文件超线但职责单一、内聚良好 — 维持现状 |
|
||||||
|
|
||||||
|
> **判定口诀**:多域混居 → 拆;单域内聚 → 留。如果"辅助函数太多"到了怀疑域本身是否单一的 程度,那问题不是该拆文件,而是该把域拆小(如 `PostService` → `CommentService` + `LikeService`),按变更原因重新切分。
|
||||||
|
|
||||||
## 瘦控制器铁律
|
## 瘦控制器铁律
|
||||||
|
|
||||||
Controller 每个方法不超过三步:
|
Controller 每个方法不超过三步:
|
||||||
@ -51,13 +159,120 @@ func (c *PostController) Create(ctx *gin.Context) {
|
|||||||
|
|
||||||
业务判断全部放在 Service 层。
|
业务判断全部放在 Service 层。
|
||||||
|
|
||||||
|
## Middleware 规范
|
||||||
|
|
||||||
|
### CSP Nonce 生成与传播
|
||||||
|
|
||||||
|
CSP Nonce **MUST** 在最外层 Middleware 中生成(每个请求一次),**MUST** 通过 `*gin.Context` 传递给后续 Handler 及模板渲染:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 正确:在 SecurityHeaders middleware 中统一生成
|
||||||
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
nonce := generateNonce()
|
||||||
|
c.Set("csp_nonce", nonce) // MUST 通过 c.Set 传递
|
||||||
|
c.Header("Content-Security-Policy",
|
||||||
|
fmt.Sprintf("script-src 'self' 'nonce-%s';", nonce))
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **MUST NOT** 在 Controller、Service、或模板内部生成 Nonce——会导致同一页面不同 `<script>` 标签拿到不同 Nonce,CSP 校验失败。
|
||||||
|
- **MUST NOT** 在 `init()` 或包级变量中缓存 Nonce——Nonce 必须每次请求重新生成(Number used ONCE)。
|
||||||
|
- 模板通过 `{{.csp_nonce}}` 获取:`<script nonce="{{.csp_nonce}}">...</script>`。
|
||||||
|
|
||||||
|
### Middleware 职责边界
|
||||||
|
|
||||||
|
Middleware **MUST** 保持单一职责,**MUST NOT** 包含以下内容:
|
||||||
|
|
||||||
|
- ❌ 数据库查询或调用 Repository
|
||||||
|
- ❌ 复杂的业务分支逻辑
|
||||||
|
- ❌ 直接操作请求体(`ctx.Request.Body`)——应使用 `ctx.ShouldBind` 系列方法
|
||||||
|
|
||||||
## Controller 禁止事项
|
## Controller 禁止事项
|
||||||
|
|
||||||
- ❌ 直接调用 Repository
|
- ❌ 直接调用 Repository
|
||||||
- ❌ 调用另一个 Controller
|
- ❌ 调用另一个 Controller
|
||||||
- ❌ 写 if-else 业务分支
|
- ❌ 写 if-else 业务分支
|
||||||
- ❌ 在 handler 里直接操作数据库
|
- ❌ 在 handler 里直接操作数据库
|
||||||
- ❌ 代码回滚、重试等编排逻辑
|
- ❌ 数据库事务回滚(`tx.Rollback()`)、重试等编排逻辑(应放在 Service 层)
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 错误包装
|
||||||
|
|
||||||
|
使用 `fmt.Errorf` + `%w` 包装底层错误,保留错误链,不得吞掉原始错误:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ❌ 错误:丢失错误链
|
||||||
|
return errors.New("创建失败")
|
||||||
|
|
||||||
|
// ✅ 正确:使用 %w 包装
|
||||||
|
return fmt.Errorf("创建文章失败: %w", err)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sentinel Error 声明
|
||||||
|
|
||||||
|
预定义错误变量放在 `internal/common/errors.go` 或对应包中,命名以 `Err` 为前缀:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("记录不存在")
|
||||||
|
ErrUnauthorized = errors.New("未授权")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误消息风格
|
||||||
|
|
||||||
|
错误字符串 **MUST NOT** 以大写字母开头,**MUST NOT** 以标点符号结尾(Go 官方惯例,因为错误经常被串联打印):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 正确
|
||||||
|
errors.New("user not found")
|
||||||
|
fmt.Errorf("创建文章失败: %w", err)
|
||||||
|
|
||||||
|
// ❌ 错误
|
||||||
|
errors.New("User not found.") // 大写开头、句号结尾
|
||||||
|
fmt.Errorf("创建文章失败。%w", err) // 句号结尾
|
||||||
|
```
|
||||||
|
|
||||||
|
> **例外**:首字母为专有名词时保持大写(如 `"OpenAI API call failed"`)。
|
||||||
|
|
||||||
|
### 错误判断
|
||||||
|
|
||||||
|
使用 `errors.Is` / `errors.As` 而非 `==` 直接比较,以兼容 `%w` 包装后的错误链:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 正确
|
||||||
|
if errors.Is(err, ErrNotFound) { ... }
|
||||||
|
|
||||||
|
// ❌ 错误:%w 包装后此判断将失败
|
||||||
|
if err == ErrNotFound { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误日志层级
|
||||||
|
|
||||||
|
| 错误类型 | 处理方式 |
|
||||||
|
|----------|----------|
|
||||||
|
| 预期内错误(参数校验、业务规则不满足) | 仅返回 error,不单独打日志 |
|
||||||
|
| 非预期错误(DB 连接失败、第三方调用失败) | Service 层 `log.Error` + 返回包装后 error |
|
||||||
|
|
||||||
|
## Context 传播
|
||||||
|
|
||||||
|
所有跨层调用的方法签名第一参数必须为 `context.Context`(Controller 方法除外,`*gin.Context` 已实现 `context.Context`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 层
|
||||||
|
func (s *PostService) Create(ctx context.Context, req dto.CreatePostRequest, userID uint) (*model.Post, error)
|
||||||
|
|
||||||
|
// Repository 层
|
||||||
|
func (r *PostRepo) FindByID(ctx context.Context, id uint) (*model.Post, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
用途:超时控制、请求取消传播、trace ID 传递。
|
||||||
|
|
||||||
|
> **禁止**:在业务方法内部使用 `context.Background()` 替代传入的 ctx。仅 `main`、`test`、`init` 可创建 Background context。
|
||||||
|
|
||||||
## 命名规范
|
## 命名规范
|
||||||
|
|
||||||
@ -65,7 +280,14 @@ func (c *PostController) Create(ctx *gin.Context) {
|
|||||||
|
|
||||||
- Go 文件:`snake_case.go`
|
- Go 文件:`snake_case.go`
|
||||||
- 模板文件:`snake_case.html`
|
- 模板文件:`snake_case.html`
|
||||||
- CSS/JS 文件:`kebab-case.css` / `camelCase.js`
|
- CSS 文件:`kebab-case.css`(遵循前端生态通用约定)
|
||||||
|
- JS 文件:`camelCase.js`(与前端 JS 生态主流惯例对齐)
|
||||||
|
|
||||||
|
### 包
|
||||||
|
|
||||||
|
- 包名全小写,不使用下划线或驼峰:`postservice`(不是 `postService` 或 `post_service`)
|
||||||
|
- 单单词优先,避免多级包名过长
|
||||||
|
- 不要用 `util`、`common`、`base` 等无意义泛名(本项目已有的 `common` 包为历史特例,新代码不允许往 `common` 追加新的业务逻辑)
|
||||||
|
|
||||||
### 结构体与方法
|
### 结构体与方法
|
||||||
|
|
||||||
@ -73,22 +295,58 @@ func (c *PostController) Create(ctx *gin.Context) {
|
|||||||
- Service:`type PostService struct{ repo *PostRepo }`,方法 `func (s *PostService) Create(...)`
|
- Service:`type PostService struct{ repo *PostRepo }`,方法 `func (s *PostService) Create(...)`
|
||||||
- Repository:`type PostRepo struct{ db *gorm.DB }`,方法 `func (r *PostRepo) FindByID(...)`
|
- Repository:`type PostRepo struct{ db *gorm.DB }`,方法 `func (r *PostRepo) FindByID(...)`
|
||||||
|
|
||||||
### 响应格式
|
### Receiver 命名
|
||||||
|
|
||||||
统一使用 `common` 包的响应函数:
|
**MUST** 使用类型名首字母缩写(1-2 个字母),同一类型的全部方法间 **MUST** 保持 receiver 名一致:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
common.Ok(ctx, data) // 200 成功
|
// ✅ 正确:首字母/首字母缩写,同类型全部方法一致
|
||||||
common.Error(ctx, common.ErrInvalidParam, msg) // 400 参数错误
|
func (s *PostService) Create(...) // PostService → s
|
||||||
common.Error(ctx, common.ErrUnauthorized, msg) // 401 未认证
|
func (s *PostService) Update(...) // 同一类型,receiver 名 MUST 相同
|
||||||
common.Error(ctx, common.ErrForbidden, msg) // 403 无权限
|
func (r *PostRepo) FindByID(...) // PostRepo → r
|
||||||
common.Error(ctx, common.ErrNotFound, msg) // 404 未找到
|
func (c *PostController) Index(...) // PostController → c
|
||||||
common.Error(ctx, common.ErrInternal, msg) // 500 服务器错误
|
|
||||||
|
// ❌ 错误
|
||||||
|
func (ps *PostService) Create(...) // 禁用多字母 receiver
|
||||||
|
func (s *PostService) Create(...) // Create 用 s
|
||||||
|
func (svc *PostService) Delete(...) // Delete 用 svc — 不一致!
|
||||||
|
func (self *PostService) List(...) // 禁用 self/this
|
||||||
|
```
|
||||||
|
|
||||||
|
### 接口命名
|
||||||
|
|
||||||
|
- 单方法接口:**MUST** 以 `-er` 后缀结尾(Go 社区惯例)。
|
||||||
|
```go
|
||||||
|
type Reader interface { Read(p []byte) (n int, err error) }
|
||||||
|
type Writer interface { Write(p []byte) (n int, err error) }
|
||||||
|
```
|
||||||
|
- 多方法接口:使用描述性名词,**MUST NOT** 加 `I` 前缀或 `Interface` 后缀。
|
||||||
|
```go
|
||||||
|
// ✅ 正确
|
||||||
|
type PostStore interface { ... }
|
||||||
|
// ❌ 错误
|
||||||
|
type IPostStore interface { ... } // 禁用 I 前缀(C#/Java 风格)
|
||||||
|
type PostStoreInterface interface { ... } // 禁用 Interface 后缀
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
|
||||||
|
统一使用 `common` 包的响应函数,HTTP 状态码直接传入:
|
||||||
|
|
||||||
|
```go
|
||||||
|
common.Ok(c, data) // 200 成功
|
||||||
|
common.OkWithMessage(c, data, "操作成功") // 200 带消息
|
||||||
|
common.OkMessage(c, "操作成功") // 200 纯消息
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误") // 400
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录") // 401
|
||||||
|
common.Error(c, http.StatusForbidden, "权限不足") // 403
|
||||||
|
common.Error(c, http.StatusNotFound, "未找到") // 404
|
||||||
|
common.Error(c, http.StatusInternalServerError, "服务器错误") // 500
|
||||||
```
|
```
|
||||||
|
|
||||||
### 模板变量
|
### 模板变量
|
||||||
|
|
||||||
使用驼峰命名,与模板文件保持一致:
|
使用驼峰命名,变量名与 Go 模板中 `{{.VariableName}}` 的引用名一致:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
gin.H{
|
gin.H{
|
||||||
@ -98,14 +356,214 @@ gin.H{
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 依赖注入
|
## 注释规范
|
||||||
|
|
||||||
当前阶段 Controller 直接在方法内创建 Service 实例。后期引入 DI 容器后统一管理。
|
### 导出符号
|
||||||
|
|
||||||
|
所有导出的函数、类型、常量、变量必须有 doc 注释,且以被注释符号的名称开头(godoc 惯例):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 当前
|
// PostService 处理文章相关的业务逻辑。
|
||||||
func (ac *AuthController) RegisterPage(c *gin.Context) { ... }
|
type PostService struct{ ... }
|
||||||
|
|
||||||
// 后期
|
// Create 创建一篇新文章,返回创建后的文章对象。
|
||||||
func NewAuthController(svc *AuthService) *AuthController { ... }
|
func (s *PostService) Create(ctx context.Context, req dto.CreatePostRequest, userID uint) (*model.Post, error) {
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 包注释
|
||||||
|
|
||||||
|
每个包应有包级注释,放在包内任一文件的 `package` 声明上方(如存在 `doc.go` 则优先放在其中):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package service 包含核心业务逻辑实现。
|
||||||
|
package service
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖注入
|
||||||
|
|
||||||
|
使用构造器注入模式,Controller/Service/Middleware 的依赖通过构造函数传入:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 构造器注入依赖
|
||||||
|
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config, siteSettings *config.SiteSettings) *AuthController {
|
||||||
|
return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg, siteSettings: siteSettings}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Controller 层通过 **ISP 接口隔离** 声明最小依赖 —— 只声明自己需要的方法子集,不依赖完整 Service 接口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// controller/admin/interfaces.go
|
||||||
|
type siteSettingUseCase interface {
|
||||||
|
GetSettings() map[string]string
|
||||||
|
UpdateSetting(key, value string) error
|
||||||
|
UpdateBoolSetting(key string, value bool) error
|
||||||
|
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
各 Controller 自身的 ISP 声明放在对应的 `interfaces.go` 文件中,严禁 controller 直接 import 完整的 Service 结构体。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
### 测试文件
|
||||||
|
|
||||||
|
测试文件命名 `xxx_test.go`,与被测文件放在**同一包内**(使用 `_test` 后缀包名进行黑盒测试除外)。
|
||||||
|
|
||||||
|
### Table-Driven Tests
|
||||||
|
|
||||||
|
使用表驱动测试(table-driven test)模式——Go 社区共识:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestCreatePost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req dto.CreatePostRequest
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"空标题应报错", dto.CreatePostRequest{Title: ""}, true},
|
||||||
|
{"正常创建", dto.CreatePostRequest{Title: "Hello"}, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := service.Create(context.Background(), tt.req, 1)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试工具
|
||||||
|
|
||||||
|
使用标准库 `testing` 包。**MUST NOT** 引入第三方断言库(如 testify),以保持依赖最小化、代码风格统一。
|
||||||
|
|
||||||
|
### 复杂结构体比较
|
||||||
|
|
||||||
|
当 `reflect.DeepEqual` 对结构体切片的比较过于啰嗦时,**MAY** 使用 `github.com/google/go-cmp/cmp` 包:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/google/go-cmp/cmp"
|
||||||
|
|
||||||
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
|
t.Errorf("CreatePost() mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `go-cmp` 是 Google 维护的标准库补充包,不算破坏"依赖最小化"原则。**MUST NOT** 为此引入 testify 或其他重量级断言框架。
|
||||||
|
|
||||||
|
## 并发规范
|
||||||
|
|
||||||
|
### Goroutine 生命周期
|
||||||
|
|
||||||
|
每个 `go func()` 启动的 goroutine **MUST** 有明确的退出路径。**MUST NOT** 启动永不退出的孤儿 goroutine。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 正确:通过 ctx.Done() 退出
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg := <-ch:
|
||||||
|
process(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ❌ 错误:无退出路径
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
process(<-ch) // 永远阻塞
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
### WaitGroup / errgroup
|
||||||
|
|
||||||
|
需要等待多个 goroutine 完成时,**MUST** 使用 `sync.WaitGroup` 或 `golang.org/x/sync/errgroup`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 使用 errgroup(推荐:支持错误传播,与 Context 集成)
|
||||||
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
|
g.Go(func() error { return fetchA(ctx) })
|
||||||
|
g.Go(func() error { return fetchB(ctx) })
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("并发获取失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 使用 WaitGroup(无错误传播需求的场景)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() { defer wg.Done(); doA() }()
|
||||||
|
go func() { defer wg.Done(); doB() }()
|
||||||
|
wg.Wait()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel 惯例
|
||||||
|
|
||||||
|
- **所有者关闭**:只有发送方 goroutine **MUST** 关闭 channel,接收方 **MUST NOT** 关闭。
|
||||||
|
- **不强制关闭只读 channel**:如果无"range 退出"需求,不关闭 channel 让 GC 回收是合法的。
|
||||||
|
- **无缓冲 vs 有缓冲**:同步通知用无缓冲 `make(chan T)`;异步队列用有缓冲 `make(chan T, size)`。
|
||||||
|
|
||||||
|
### sync 原语
|
||||||
|
|
||||||
|
- `sync.Mutex` **MUST** 通过 `defer mu.Unlock()` 释放,**MUST NOT** 存在 `Unlock` 后还有 `return` 分支。
|
||||||
|
- **MUST NOT** 复制含有 `sync.Mutex` / `sync.RWMutex` 的结构体(`go vet` 会检测)。
|
||||||
|
|
||||||
|
### Context 超时
|
||||||
|
|
||||||
|
所有可能长时间阻塞的 I/O 操作(HTTP 请求、DB 查询、外部 RPC 调用)**MUST** 使用带超时的 Context:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 正确
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
result, err := repo.FindByID(ctx, id)
|
||||||
|
|
||||||
|
// ❌ 错误:直接使用原始 ctx,无超时保护
|
||||||
|
result, err := repo.FindByID(ctx, id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
| 原则 | 含义 | 本项目实践 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| **YAGNI** | You Ain't Gonna Need It — 不为可能的需求提前实现 | 分 Phase 推进,不做预留接口/未用抽象层 |
|
||||||
|
| **DIP**(依赖倒置) | 高层模块不依赖低层,都依赖抽象 | Controller → ISP 接口 ← Service;Service → Store 接口 ← Repository |
|
||||||
|
| **SRP**(单一职责) | 一个模块只为一个角色变更 | 拆分决策:「多域混居则拆,内聚共享则留」 |
|
||||||
|
| **OCP**(开闭原则) | 对扩展开放,对修改关闭 | `config.yaml` 角色/审核配置可扩展,不改代码 |
|
||||||
|
| **ISP**(接口隔离) | 不依赖用不到的接口 | Controller 只声明自己需要的方法子集,见下方 ISP 章节 |
|
||||||
|
| **KISS** | Keep It Simple | 连续字符校验纯函数、密码强度 switch-case,不做策略模式/DI 框架 |
|
||||||
|
|
||||||
|
### 架构风格
|
||||||
|
|
||||||
|
分层架构 + ISP 接口隔离,非纯六边形(即不在所有方向都引入 Port/Adapter 抽象,仅在业务边界 Controller ↔ Service、Service ↔ Repository 做接口倒置)。Gin、GORM 等框架层不额外抽象:
|
||||||
|
|
||||||
|
```
|
||||||
|
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
|
||||||
|
↓
|
||||||
|
Service 核心逻辑
|
||||||
|
↓
|
||||||
|
Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重构原则
|
||||||
|
|
||||||
|
### 不向后兼容
|
||||||
|
|
||||||
|
本项目为全新项目,尚未发布正式版。**重构时直接废弃旧实现,不做兼容层**,不保留任何向后兼容代码。遇到旧代码直接删除/覆盖,不新增 `deprecated`、`legacy` 等标记或包装函数。
|
||||||
|
|
||||||
|
### CMS 化
|
||||||
|
|
||||||
|
**纳入 CMS 配置化的内容**(站点级可配置文案,需管理后台提供对应输入项):
|
||||||
|
- 品牌名、标语、版权声明、联系邮箱
|
||||||
|
- 页面标题前缀/后缀、SEO 描述
|
||||||
|
- 空状态占位文案、系统级通知横幅
|
||||||
|
- 其他面向最终用户展示的站点文本
|
||||||
|
|
||||||
|
**不受此规则约束的例外**(无需配置化):
|
||||||
|
- 表单校验错误消息(如"用户名不能为空")— 属于应用逻辑,不是站点配置
|
||||||
|
- `common.Error()` 的通用错误提示(如"参数错误""服务器错误")— 程序内部语义
|
||||||
|
- CSS 注释、JS debug 日志、代码中的技术常量
|
||||||
|
|||||||
147
docs/deployment.md
Normal file
147
docs/deployment.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# 生产环境部署指南
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
| 依赖 | 最低版本 | 用途 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Go | 1.26 | 编译 |
|
||||||
|
| PostgreSQL | 16 | 主数据库(用户、帖子、通知等) |
|
||||||
|
| Redis | 7 | 会话存储(推荐) |
|
||||||
|
| Nginx | 1.24+ | 反向代理 + 静态资源 |
|
||||||
|
|
||||||
|
## 1. 编译
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o /usr/local/bin/mce cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 数据库初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建数据库
|
||||||
|
sudo -u postgres createuser metazone -P
|
||||||
|
sudo -u postgres createdb metalab_prod -O metazone
|
||||||
|
|
||||||
|
# 执行迁移
|
||||||
|
for f in docs/migrations/0*.sql; do
|
||||||
|
psql -U metazone -d metalab_prod -f "$f"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env`,填入生产环境值:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务
|
||||||
|
SERVER_MODE=release
|
||||||
|
|
||||||
|
# 数据库(生产实例地址)
|
||||||
|
DATABASE_HOST=10.0.0.1
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=metazone
|
||||||
|
DATABASE_PASSWORD=your_secure_password
|
||||||
|
DATABASE_DBNAME=metalab_prod
|
||||||
|
DATABASE_SSLMODE=require
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
REDIS_HOST=10.0.0.2
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
REDIS_DB=0
|
||||||
|
```
|
||||||
|
|
||||||
|
> `config.yaml` 中的默认值会被 `.env` 覆盖,通常无需修改。
|
||||||
|
|
||||||
|
## 4. systemd 服务
|
||||||
|
|
||||||
|
创建 `/etc/systemd/system/mce.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=MetaZone Community Engine
|
||||||
|
After=network.target postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mce
|
||||||
|
WorkingDirectory=/opt/mce
|
||||||
|
ExecStart=/usr/local/bin/mce
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now mce
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Nginx 反向代理
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/your-domain.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||||
|
|
||||||
|
# 静态资源(Gin 不处理)
|
||||||
|
location /static/ {
|
||||||
|
root /opt/mce/templates/MetaLab-2026;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务状态
|
||||||
|
sudo systemctl status mce
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
sudo journalctl -u mce -f
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
curl -I https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 首次注册站长
|
||||||
|
|
||||||
|
部署后首个注册用户自动获得 `owner`(站长)权限,可访问 `/admin` 管理后台。
|
||||||
|
|
||||||
|
## 备份建议
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库备份
|
||||||
|
pg_dump -U metazone metalab_prod | gzip > backup_$(date +%Y%m%d).sql.gz
|
||||||
|
|
||||||
|
# 上传文件备份
|
||||||
|
rsync -av /opt/mce/storage/ /backup/storage/
|
||||||
|
```
|
||||||
@ -1,562 +1,194 @@
|
|||||||
# 开发记录 — 阶段一:基础设施 + 用户认证(含"记住我"与令牌刷新)
|
# 开发记录 — MetaLab RC
|
||||||
|
|
||||||
> 最后更新:2026-05-24
|
> 最后更新:2026-06-22 | 版本:v0.1.0-rc1
|
||||||
|
|
||||||
## 一、已完成事项
|
## 一、架构总览
|
||||||
|
|
||||||
### 1. 数据库配置与连接
|
|
||||||
|
|
||||||
**问题**:PostgreSQL 使用 `peer` 认证,Go 进程以系统用户 `victor_jay` 运行,无法连接数据库用户 `metazone`。
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
- 为 `metazone` 数据库用户设置密码(`MetalabDev2026!`)
|
|
||||||
- 连接方式从 Unix socket(`/var/run/postgresql`)改为 TCP(`127.0.0.1:5432`),触发 `scram-sha-256` 密码认证
|
|
||||||
- 数据库名称:`metalab_dev`
|
|
||||||
|
|
||||||
**涉及文件**:`config.yaml`(`database.host` 改为 `127.0.0.1`)、`.env`(`DATABASE_PASSWORD`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 配置加载架构修复
|
|
||||||
|
|
||||||
**问题**:Viper 的 `SetConfigFile(".env")` 会**覆盖**之前设置的 `config.yaml` 路径,导致所有 YAML 配置被清空,数据库名称为空。
|
|
||||||
|
|
||||||
**根本原因**:Viper 不支持同时加载多个不同类型的配置文件。
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
|
|
||||||
`internal/config/config.go` 中的加载流程:
|
|
||||||
```
|
|
||||||
1. loadEnvFile(".env") → 逐行读取,通过 os.Setenv() 注入环境变量
|
|
||||||
2. viper 只读取 config.yaml
|
|
||||||
3. bindEnvOverride() → 将 DATABASE_PASSWORD、JWT_SECRET 显式绑定到对应配置键
|
|
||||||
4. viper.AutomaticEnv() → 自动读取其余环境变量
|
|
||||||
```
|
|
||||||
|
|
||||||
环境变量优先级:**系统环境变量 > .env 文件**(`.env` 仅在对应环境变量未设置时生效)
|
|
||||||
|
|
||||||
**涉及文件**:`internal/config/config.go`(重写 `Load()` 函数)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 数据模型设计
|
|
||||||
|
|
||||||
#### 3.1 公共基础模型 (`common.go`)
|
|
||||||
|
|
||||||
| 字段 | 类型 | GORM 标签 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ID` | `uint` | `primarykey;column:uid` | 主键,数据库列名为 `uid`,自增 bigint(非 UUID) |
|
|
||||||
| `CreatedAt` | `time.Time` | — | 自动管理 |
|
|
||||||
| `UpdatedAt` | `time.Time` | — | 自动管理 |
|
|
||||||
| `DeletedAt` | `gorm.DeletedAt` | `index;json:"-"` | 软删除,JSON 不暴露 |
|
|
||||||
|
|
||||||
#### 3.2 用户模型 (`user.go`)
|
|
||||||
|
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `Email` | `varchar(255)` | `uniqueIndex; not null` | 注册和登录的唯一凭证 |
|
|
||||||
| `PasswordHash` | `varchar(255)` | `not null; json:"-"` | bcrypt(12),JSON 永不暴露 |
|
|
||||||
| `Username` | `varchar(16)` | `uniqueIndex; not null` | 全局唯一,10 位随机 `[a-z0-9]` 自动生成 |
|
|
||||||
| `Avatar` | `varchar(500)` | `default:''` | 预留头像字段 |
|
|
||||||
| `Bio` | `text` | — | 预留个人简介 |
|
|
||||||
| `Role` | `varchar(20)` | `default:user; index` | `user` / `moderator` / `admin` |
|
|
||||||
| `Status` | `varchar(20)` | `default:active; index` | `active` / `banned` |
|
|
||||||
|
|
||||||
**关键设计决策:**
|
|
||||||
|
|
||||||
| 决策项 | 选择 | 原因 |
|
|
||||||
|---|---|---|
|
|
||||||
| 主键方案 | 自增 bigint(`uid` 列)而非 UUID | 社区应用,索引性能更好,URL 简洁 `/users/42`,`JOIN` 体积小 |
|
|
||||||
| 角色控制 | `role string` 而非 `is_admin bool` | 可扩展(已有 user/moderator/admin 三级),符合最佳实践 |
|
|
||||||
| 用户名生成 | `crypto/rand` 生成 10 位 `[a-z0-9]` | 3,656 万亿种组合,千万用户碰撞概率 < 0.01% |
|
|
||||||
| 用户名长度 | `varchar(16)` | 作为最大长度限制(暂未支持中文字符名) |
|
|
||||||
| 密码哈希 | bcrypt(12) | 行业标准,成本 12 平衡安全与性能 |
|
|
||||||
| 软删除 | GORM `DeletedAt` | 保留数据,误删可恢复 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 用户注册
|
|
||||||
|
|
||||||
**流程**:
|
|
||||||
```
|
|
||||||
POST /api/auth/register
|
|
||||||
→ 验证 Email/Password/ConfirmPassword(binding 标签)
|
|
||||||
→ Service.Register()
|
|
||||||
→ validatePassword():≥8位 + 含字母 + 含数字
|
|
||||||
→ userRepo.FindByEmail():邮箱查重
|
|
||||||
→ bcrypt.GenerateFromPassword(,12):密码哈希
|
|
||||||
→ generateUsername():生成 10 位随机用户名,重试 20 次去重
|
|
||||||
→ userRepo.Create():写入数据库
|
|
||||||
→ buildToken():生成 access JWT(HS256,15 分钟)
|
|
||||||
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT(HS256,30 天)
|
|
||||||
→ SetAuthCookies(accessToken, refreshToken) → 设置 Cookie
|
|
||||||
→ 返回 JSON { success, message, data: user }
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键点**:注册即登录,Cookie 自动设置,前端无需额外操作。勾选"记住我"则下发 refresh token + 标记 Cookie,未勾选则不下发。
|
|
||||||
|
|
||||||
**涉及文件**:
|
|
||||||
- `internal/controller/auth_controller.go` — `Register()` POST handler
|
|
||||||
- `internal/service/auth_service.go` — `Register()`、`validatePassword()`、`generateUsername()`、`buildToken()`、`buildRefreshToken()`
|
|
||||||
- `internal/repository/user_repo.go` — `Create()`、`FindByEmail()`
|
|
||||||
- `internal/model/dto.go` — `RegisterRequest`(binding 校验标签 + `remember_me`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 用户登录
|
|
||||||
|
|
||||||
**流程**:
|
|
||||||
```
|
|
||||||
POST /api/auth/login
|
|
||||||
→ 验证 Email/Password
|
|
||||||
→ Service.Login()
|
|
||||||
→ userRepo.FindByEmail():查找用户
|
|
||||||
→ 检查 Status != banned(封禁用户拒绝登录)
|
|
||||||
→ bcrypt.CompareHashAndPassword():密码校验
|
|
||||||
→ buildToken():生成 access JWT
|
|
||||||
→ 如果 req.RememberMe → buildRefreshToken():生成 refresh JWT
|
|
||||||
→ SetAuthCookies(accessToken, refreshToken)
|
|
||||||
→ 返回 JSON { success, message, data: user }
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误处理**:根据错误类型返回不同 HTTP 状态码和消息:
|
|
||||||
- 401:邮箱不存在 / 密码错误
|
|
||||||
- 403:用户已封禁
|
|
||||||
- 500:服务器内部错误
|
|
||||||
|
|
||||||
**涉及文件**:`auth_controller.go`(`Login()`)、`auth_service.go`(`Login()`、`ErrUserBanned`、`buildRefreshToken()`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. JWT 认证中间件
|
|
||||||
|
|
||||||
| 中间件 | 用途 | 注入上下文的值 |
|
|
||||||
|---|---|---|
|
|
||||||
| `AuthRequired` | 严格认证,失败返回 401 | `uid`、`email`、`username`、`role` |
|
|
||||||
| `AuthOptional` | 可选认证,有 token 就注入,没有也放行 | 同上(无 token 时不注入) |
|
|
||||||
|
|
||||||
**Cookie 配置**:
|
|
||||||
|
|
||||||
| Cookie | 名称 | HttpOnly | Secure | SameSite | 过期 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Access token | `mlb_token` | ✅ | ✅(debug 模式 HTTP) | Lax | 15 min |
|
|
||||||
| Refresh token | `mlb_refresh` | ✅ | ✅ | Lax | 30 day(记住我时) |
|
|
||||||
| 记住我标记 | `mlb_rm` | ❌ | ✅ | — | 30 day(记住我时) |
|
|
||||||
|
|
||||||
**中间件公开函数**:
|
|
||||||
|
|
||||||
| 函数 | 用途 |
|
|
||||||
|---|---|
|
|
||||||
| `SetAuthCookies(c, accessToken, refreshToken, cfg)` | 设置 access + refresh(+ mlb_rm 标记) |
|
|
||||||
| `SetAccessCookie(c, accessToken, cfg)` | 仅刷新 access cookie(续期调用) |
|
|
||||||
| `ClearAuthCookies(c, cfg)` | 清除全部三个 Cookie(登出/过期) |
|
|
||||||
|
|
||||||
**涉及文件**:`internal/middleware/auth.go`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. 登录状态注入(SSR 模板渲染)
|
|
||||||
|
|
||||||
`internal/common/helper.go` 中的 `BuildPageData()`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func BuildPageData(c *gin.Context, extra gin.H) gin.H
|
|
||||||
```
|
|
||||||
|
|
||||||
- 将 `extra` 键值对复制到 `gin.H`
|
|
||||||
- 若 Gin 上下文中存在 `username`(由 `AuthOptional` 中间件注入),自动添加:
|
|
||||||
- `IsLoggedIn: true`
|
|
||||||
- `Username: "..."`
|
|
||||||
- 所有页面 Controller 通过此函数构建模板数据
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. 页面路由与模板
|
|
||||||
|
|
||||||
| 路由 | 方法 | 中间件 | 模板 | 说明 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `/` | GET | `AuthOptional` | `home/index.html` | 已登录显示首页,未登录重定向 `/auth/login` |
|
|
||||||
| `/auth/register` | GET | `AuthOptional` | `auth/register.html` | 已登录重定向 `/` |
|
|
||||||
| `/auth/login` | GET | `AuthOptional` | `auth/login.html` | 已登录重定向 `/` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. NAV 导航栏逻辑
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if .IsLoggedIn}}
|
|
||||||
→ 显示用户名链接(指向 /settings)+ "退出"按钮
|
|
||||||
{{else}}
|
|
||||||
→ 显示"登录"按钮(指向 /auth/login)
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
**设计决策**:NAV 只显示"登录"按钮,不显示独立"注册"按钮。注册入口在登录页面内部提供(作为开发者社区,用户应能理解)。已登录用户可通过"退出"按钮清除所有 Cookie 并返回首页。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. 模板加载器重构
|
|
||||||
|
|
||||||
**问题**:原加载器使用 `info.Name()`(纯文件名),导致 `home/index.html` 注册为 `index.html`,与路由引用的 `home_index.html` 不匹配,模板渲染失败。
|
|
||||||
|
|
||||||
**解决**:改为使用相对路径作为模板名:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
layout/header.html
|
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
|
||||||
layout/nav.html
|
↓
|
||||||
layout/footer.html
|
Service 核心逻辑
|
||||||
auth/register.html
|
↓
|
||||||
auth/login.html
|
Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
|
||||||
home/index.html
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`{{template}}` 引用全部更新为这些相对路径名。
|
| 层次 | 职责 | 单文件上限 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| Router | 路由映射 + 依赖注入 | ≤ 60 行 |
|
||||||
|
| Controller | 参数绑定 + 调用服务 + 返回 | ≤ 120 行 |
|
||||||
|
| Middleware | 认证/授权/安全头/限流 | ≤ 60 行 |
|
||||||
|
| Service | 业务逻辑 | ≤ 300 行 |
|
||||||
|
| Repository | 纯数据库操作 | ≤ 200 行 |
|
||||||
|
| Model | 纯结构体 | ≤ 80 行 |
|
||||||
|
|
||||||
**涉及文件**:`internal/theme/loader.go`、所有 `.html` 模板文件中的 `{{template}}` 引用
|
## 二、已完成阶段
|
||||||
|
|
||||||
---
|
### Phase 1:基础设施 + 用户认证
|
||||||
|
|
||||||
### 11. "记住我" + 刷新令牌(Refresh Token)
|
- PostgreSQL + AutoMigrate + 部分唯一索引(软删除用户邮箱复用)
|
||||||
|
- Gin + 模板渲染 + 静态资源哈希缓存破坏
|
||||||
|
- 用户注册/登录/登出 + bcrypt(12) 密码哈希
|
||||||
|
- 服务端 Session(滑动窗口续期):Redis + 内存 FallbackStore 自动降级
|
||||||
|
- 全局 `SecurityHeaders` 中间件(CSP nonce、HSTS、X-Frame-Options、X-Content-Type-Options)
|
||||||
|
- `InjectSiteInfo` 中间件注入站点品牌信息到所有 SSR 页面
|
||||||
|
- 角色系统(user/moderator/admin/owner,OCP 可扩展)
|
||||||
|
- `BuildPageData()` / `BuildAdminPageData()` 统一模板数据注入
|
||||||
|
- CSRF Double Submit Cookie 保护
|
||||||
|
- 敏感操作限流(`SensitiveRateLimit`:改密/注销/签到)+ 登录限流(账户+IP 双维度滑动窗口)
|
||||||
|
- 维护模式(仅 owner 可访问,其余用户重定向登录页)
|
||||||
|
|
||||||
#### 11.1 设计目标
|
### Phase 2:公告系统
|
||||||
|
|
||||||
| 场景 | 行为 |
|
- 公告 CRUD + 管理后台管理页面
|
||||||
|---|---|
|
- 前台首页公告区域
|
||||||
| 用户勾选"记住我" | 双 Cookie:`mlb_token`(15min) + `mlb_refresh`(30d) |
|
|
||||||
| 用户未勾选 | 单 Cookie:`mlb_token`(15min),过期需重新登录 |
|
|
||||||
| 页面持续开着 | 前端每 10 分钟静默调用 `/api/auth/refresh` 续期 access token |
|
|
||||||
| 关闭浏览器 15+ min 后回来 | access token 过期 → JS 自动刷新 → 页面 reload 恢复登录态 |
|
|
||||||
| 30 天内任意时间回来 | refresh token 仍有效,自动静默恢复登录 |
|
|
||||||
|
|
||||||
#### 11.2 Cookie 架构
|
### Phase 3:文章属性
|
||||||
|
|
||||||
| Cookie 名 | 作用 | HttpOnly | JS 可读 | 过期时间 | 说明 |
|
- Post 模型:Visibility(public/private)、PostType(original/reprint)、ReprintSource
|
||||||
|---|---|---|---|---|---|
|
- 创作声明(7 种)、禁止转载标记
|
||||||
| `mlb_token` | access token | ✅ | ❌ | 15 min | JWT,含 `uid/email/username/role/exp/iat` |
|
- 文章置顶(category/global)+ 管理后台 Pin/Unpin API
|
||||||
| `mlb_refresh` | refresh token | ✅ | ❌ | 30 day(勾选记住我时) | JWT,含 `uid/email/username/role/exp/iat/purpose:"refresh"` |
|
- 原创/转载:写文章页条件联动
|
||||||
| `mlb_rm` | 记住我标记 | ❌ | ✅ | 30 day(勾选记住我时) | 值为 `"1"`,仅标记位,非凭据 |
|
- 模板函数 `declarationLabel()`
|
||||||
|
|
||||||
**设计决策 — `mlb_rm` 标记 Cookie**:
|
### Phase 4:分类系统 + 标签系统
|
||||||
- 本质是**非安全标记位**(not a credential),用于 JS 判断"是否该尝试刷新"
|
|
||||||
- 如果不存在(未勾选记住我),`common.js` 跳过所有刷新逻辑 → **不会产生 401 控制台报错**
|
|
||||||
- 值仅为 `"1"`,即使被 XSS 读取也无法用于任何认证操作
|
|
||||||
|
|
||||||
#### 11.3 配置 (`config.yaml` + `config.go`)
|
- Category 两级树形结构 + 管理后台页面
|
||||||
|
- Tag 系统 + 落地页 `/tags/{slug}`
|
||||||
|
- 写文章页分类选择 + 标签输入
|
||||||
|
|
||||||
```yaml
|
### Phase 5:定时发布
|
||||||
jwt:
|
|
||||||
access_expire: 15 # 分钟
|
- Post 模型 `ScheduledAt` + 审核逻辑
|
||||||
refresh_expire: 168 # 小时 (7 天) — 备用字段,当前未启用
|
- Scheduler goroutine 每分钟扫描发布到期文章
|
||||||
remember_expire: 720 # 小时 (30 天) — refresh token 有效期
|
- 前端日期时间选择器(min=now+30min)
|
||||||
|
|
||||||
|
### Phase 6:收尾与审计修复(2026-06-22)
|
||||||
|
|
||||||
|
**安全加固:**
|
||||||
|
| 项目 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| CSP | `'unsafe-inline'` 移除,nonce 替代(`'unsafe-eval'` 保留供 Vditor) |
|
||||||
|
| HSTS | 始终启用 `max-age=31536000; includeSubDomains; preload` |
|
||||||
|
| 密码强度 | 四级(low/medium/high/very_high)+ 连续字符检查 |
|
||||||
|
| 注册限流 | IP 维度 1min×5 次 |
|
||||||
|
| CSRF Token 日志 | 仅记录长度,不记明文 |
|
||||||
|
| DB 健康降级 | 5s ping + 全局 `dbHealthy` 标志 + 中间件拦截返回固定错误页 |
|
||||||
|
| 文件上传 | `image.Decode` 强制解码验证(替代 magic bytes) |
|
||||||
|
|
||||||
|
**代码质量:**
|
||||||
|
| 项目 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| golangci-lint | 57 告警 → 0,CI strict 模式零告警 |
|
||||||
|
| 包注释 | 16 个包全部添加 |
|
||||||
|
| 错误消息 | 全部小写开头 |
|
||||||
|
| errorlint | `==` 比较 → `errors.Is` |
|
||||||
|
| unused 代码 | `hasUnicode`/`energyStore`/`current`/`energyAdminUseCase` 移除 |
|
||||||
|
| gofumpt | 全量格式化 |
|
||||||
|
|
||||||
|
**架构重构:**
|
||||||
|
| 项目 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `router/api.go` | 198 行拆为 7 个域文件(auth/settings/posts/comments/reactions/social/studio) |
|
||||||
|
| 管理后台站点设置 | 单页 310 行拆为 4 个子页(brand/security/registration/content)+ 子菜单,旧路由 301 重定向 |
|
||||||
|
| 前台用户设置 | 1201 行拆为 6 个独立模板 + 6 个独立 JS 文件 |
|
||||||
|
| Controller Exp 读取 | `common.GetGinExp()` 封装,不再直接读 context |
|
||||||
|
| UID 解析 | `common.ParseUIDParam()` 统一,follow/admin controller 改用 |
|
||||||
|
| BaseModel 统一 | Post/Comment/Announcement/Category/Folder/FolderItem 嵌入 BaseModel,`primaryKey` → `primarykey` |
|
||||||
|
| 时区 | `config.yaml server.timezone` → `time.Local` 初始化 |
|
||||||
|
|
||||||
|
**Footer 合规信息:**
|
||||||
|
- `site_settings.go` 扩展 8 个字段(operator/ICP/公安/文网文/算法备案/隐私/条款)
|
||||||
|
- `footer.html` 渲染合规行(`safeHTML` 模板函数,空值跳过)
|
||||||
|
- 管理后台合规信息输入区
|
||||||
|
|
||||||
|
**注册准则控制:**
|
||||||
|
- 三层控制:`show_guidelines` / `force_guidelines` / `guidelines_timer`
|
||||||
|
- 管理后台注册设置区
|
||||||
|
|
||||||
|
## 三、文件清单
|
||||||
|
|
||||||
|
```text
|
||||||
|
mce/
|
||||||
|
├── cmd/server/main.go # 入口:配置加载、DB/Redis、模板、路由、调度器
|
||||||
|
├── config.yaml # 默认配置
|
||||||
|
├── .golangci.yml # golangci-lint v2 配置
|
||||||
|
├── .gitea/workflows/ci.yml # CI 流水线(strict 模式)
|
||||||
|
├── scripts/ci.sh # CI 脚本(支持 --strict)
|
||||||
|
├── Makefile # fmt/lint/ci/test 命令
|
||||||
|
├── internal/
|
||||||
|
│ ├── common/ # 通用工具:响应格式、Context 提取、模板数据构建、错误哨兵
|
||||||
|
│ ├── config/ # 配置加载 + SiteSettings DB 持久化内存缓存
|
||||||
|
│ ├── model/ # 所有数据模型(25 个)
|
||||||
|
│ ├── repository/ # GORM 数据访问(17 个 repo)
|
||||||
|
│ ├── service/ # 业务逻辑(18 个 service,含 repository.go 接口定义)
|
||||||
|
│ ├── controller/ # Gin handler(38 个 controller 文件)
|
||||||
|
│ │ └── admin/ # 管理后台 controller(7 个)
|
||||||
|
│ ├── middleware/ # 中间件:认证/CSRF/安全头/限流/维护/DB健康
|
||||||
|
│ ├── router/ # 路由注册 + 依赖注入(按域拆分)
|
||||||
|
│ ├── session/ # 服务端 Session:Redis + Memory + Fallback
|
||||||
|
│ ├── scheduler/ # 定时发布调度器
|
||||||
|
│ ├── cache/ # 首页缓存
|
||||||
|
│ └── theme/ # 模板加载器 + safeHTML 函数
|
||||||
|
├── templates/
|
||||||
|
│ ├── MetaLab-2026/ # 前端主题
|
||||||
|
│ │ ├── html/ # Go 模板(按功能分目录)
|
||||||
|
│ │ └── static/ # CSS/JS/图片 + Vditor 编辑器
|
||||||
|
│ ├── admin/ # 管理后台模板
|
||||||
|
│ └── shared/ # 共享静态资源
|
||||||
|
└── docs/ # 设计文档
|
||||||
```
|
```
|
||||||
|
|
||||||
`JWTConfig` 新增字段:
|
## 四、关键设计决策
|
||||||
```go
|
|
||||||
RememberExpire int `mapstructure:"remember_expire"` // 小时
|
|
||||||
```
|
|
||||||
**关键**:`mapstructure` 标签**不可省略**,否则 Viper 无法将 YAML 键映射到结构体字段,导致过期时间全为 0。
|
|
||||||
|
|
||||||
#### 11.4 请求 DTO (`dto.go`)
|
|
||||||
|
|
||||||
`LoginRequest` 和 `RegisterRequest` 都新增了 `RememberMe` 字段:
|
|
||||||
```go
|
|
||||||
RememberMe bool `json:"remember_me"`
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 11.5 后端业务逻辑 (`auth_service.go`)
|
|
||||||
|
|
||||||
**变更的方法:**
|
|
||||||
|
|
||||||
| 方法 | 旧返回值 | 新返回值 |
|
|
||||||
|---|---|---|
|
|
||||||
| `Register()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
|
|
||||||
| `Login()` | `(token, user, error)` | `(accessToken, refreshToken, user, error)` |
|
|
||||||
|
|
||||||
当 `req.RememberMe == false` 时,`refreshToken` 返回空字符串 `""`。
|
|
||||||
|
|
||||||
**新增方法:**
|
|
||||||
|
|
||||||
| 方法 | 签名 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `buildRefreshToken(user)` | `(string, error)` | 构建 30 天过期 JWT,`purpose` claim 为 `"refresh"` |
|
|
||||||
| `RefreshAccessToken(refreshTokenStr)` | `(string, error)` | 校验 refresh token → 返回新 access token |
|
|
||||||
|
|
||||||
**`RefreshAccessToken` 校验流程**:
|
|
||||||
```
|
|
||||||
1. 解析 refresh token JWT
|
|
||||||
→ 失败:ErrTokenExpired(登录已过期)
|
|
||||||
2. 校验 claims["purpose"] == "refresh"
|
|
||||||
→ 不匹配:ErrTokenInvalid(防止 access token 被用于刷新)
|
|
||||||
3. 从 claims 提取 uid/email/username/role → 构建新 access token
|
|
||||||
```
|
|
||||||
|
|
||||||
**安全点**:`purpose: "refresh"` 声明确保**access token 无法充当 refresh token**,防止短生命周期令牌被滥用为长期凭据。
|
|
||||||
|
|
||||||
#### 11.6 Session 恢复实现(前端 JS)
|
|
||||||
|
|
||||||
`common.js` 中的自动刷新逻辑:
|
|
||||||
|
|
||||||
```
|
|
||||||
页面加载(非 /auth/* 路径)
|
|
||||||
→ 检查 document.cookie 是否存在 mlb_rm
|
|
||||||
→ 不存在 → 跳过所有刷新(无 401 报错)
|
|
||||||
→ 存在 → fetch POST /api/auth/refresh
|
|
||||||
→ 成功:
|
|
||||||
1. 启动 10 分钟定时器
|
|
||||||
2. 页面未显示登录态(无 logoutBtn)?
|
|
||||||
→ sessionStorage 防重 → window.location.reload()
|
|
||||||
重载后 access token 存在 → SSR 正确渲染登录状态
|
|
||||||
→ 失败 → 停止定时器(用户需手动重新登录)
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键实现细节**:
|
|
||||||
- 用 `fetch()` 而非 `XMLHttpRequest`:`fetch` 对 HTTP 非 2xx 不会触发浏览器控制台静默日志
|
|
||||||
- `sessionStorage` 防无限循环:`mlb_refreshed` 标志确保 reload 只执行一次
|
|
||||||
- 不在认证页面执行:`/auth/login`、`/auth/register` 路径直接跳过
|
|
||||||
|
|
||||||
#### 11.7 界面
|
|
||||||
|
|
||||||
登录页和注册页各增加一个复选框:
|
|
||||||
```html
|
|
||||||
<div class="form-group remember-group">
|
|
||||||
<label class="remember-label">
|
|
||||||
<input type="checkbox" id="rememberMe">
|
|
||||||
保持登录 30 天
|
|
||||||
</label>
|
|
||||||
<span class="remember-hint">非共享设备推荐</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
**默认不勾选**——公共环境(教室、网吧、共享电脑)不会意外留下长期凭据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. 登出功能
|
|
||||||
|
|
||||||
#### 12.1 后端 API
|
|
||||||
|
|
||||||
**路由**:`POST /api/auth/logout`
|
|
||||||
|
|
||||||
**处理流程**(`auth_controller.go` → `Logout()`):
|
|
||||||
```
|
|
||||||
1. middleware.ClearAuthCookies(c, cfg)
|
|
||||||
→ 清除 mlb_token、mlb_refresh、mlb_rm 三个 Cookie(MaxAge=-1)
|
|
||||||
2. 返回 JSON { success: true, message: "已退出登录" }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 12.2 前端
|
|
||||||
|
|
||||||
**NAV 导航栏**(`nav.html`):
|
|
||||||
```html
|
|
||||||
{{if .IsLoggedIn}}
|
|
||||||
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
|
|
||||||
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
|
|
||||||
{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
**JS 处理**(`common.js`):
|
|
||||||
```
|
|
||||||
点击"退出" → POST /api/auth/logout → Cookie 清除完毕 → 跳转首页
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. 刷新令牌 API
|
|
||||||
|
|
||||||
**路由**:`POST /api/auth/refresh`
|
|
||||||
|
|
||||||
**处理流程**(`auth_controller.go` → `RefreshToken()`):
|
|
||||||
```
|
|
||||||
1. 从 Cookie 读取 mlb_refresh
|
|
||||||
→ 为空:401 "请重新登录"
|
|
||||||
2. 调用 authService.RefreshAccessToken(refreshToken)
|
|
||||||
→ 失败:ClearAuthCookies() + 401 "登录已过期"
|
|
||||||
3. 成功:SetAccessCookie() → 下发新 access token
|
|
||||||
→ 返回 200 { success: true }
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:此端点**只刷新 access token**,不刷新 refresh token。refresh token 有 30 天固定有效期,到期后用户需手动登录。这避免了无限续期带来的安全隐患(长期不活跃的 refresh token 最终会自然过期)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 14. 个人设置页与头像上传
|
|
||||||
|
|
||||||
**涉及文件**:参考 [`docs/settings.md`](settings.md) 获取完整实现文档。
|
|
||||||
|
|
||||||
**功能概要**:
|
|
||||||
|
|
||||||
| 功能 | 实现文件 |
|
|
||||||
|------|----------|
|
|
||||||
| 用户名编辑 | `auth_service.go` — `UpdateProfile()`,正则 `[\p{Han}a-zA-Z0-9_-]` 白名单 |
|
|
||||||
| 个性签名编辑 | 同上,0-128 字符 |
|
|
||||||
| 头像处理管线 | `avatar_service.go`(新文件)— 解码→裁切→CatmullRom缩放→WebP二分编码→原子写入 |
|
|
||||||
| 前端裁切弹窗 | `settings/index.html` — 固定框 + 图片平移/缩放 + 坐标映射 |
|
|
||||||
| CSS样式 | `settings.css` — 浅色主题裁切弹窗、Toast通知、输入框样式 |
|
|
||||||
| CSP放宽 | `security.go` — `img-src 'self' data:`(裁切预览用data URI) |
|
|
||||||
| 静态路由 | `main.go` — `/uploads/avatars` → `./storage/uploads/avatars` |
|
|
||||||
|
|
||||||
**设计决策**:
|
|
||||||
|
|
||||||
| 决策 | 选择 | 原因 |
|
| 决策 | 选择 | 原因 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 用户名校验 | 白名单 regex | 比黑名单更安全,直接排除 `<>&"'` 等 XSS 向量 |
|
| Web 框架 | Gin | 性能好,生态成熟 |
|
||||||
| 字符计数 | `utf8.RuneCountInString` | 确保与 PostgreSQL `varchar(16)` 语义一致 |
|
| ORM | GORM v2 | AutoMigrate + 软删除 + 回调 |
|
||||||
| 头像编码 | WebP + 二分查找质量 | 目标 ≤100KB,兼顾质量与体积 |
|
| 数据库 | PostgreSQL | 社区级应用首选 |
|
||||||
| 原子写入 | `.tmp` → `os.Rename` | 防止写入中断产生损坏文件 |
|
| 会话 | 服务端 Session | 支持踢出设备、登录管理 |
|
||||||
| 裁切方式 | 固定框 + 图片平移/缩放 | 框大小不变,zoom使框覆盖更小区域,真正支持精确定位 |
|
| 存储 | Redis + Memory FallbackStore | Redis 不可达自动降级,恢复自动切回 |
|
||||||
| GIF支持 | 不支持 | 裁切后无法保持动画,无实际用途 |
|
| 模板 | Go html/template | 无前端框架依赖,SSR 直出 |
|
||||||
| 最小分辨率 | 128×128 | 防止马赛克图被拉升到 512×512 |
|
| 编辑器 | Vditor v3.11.2(本地部署) | Markdown 所见即所得 |
|
||||||
| CSP data: URI | 显式允许 img-src | 裁切预览使用 FileReader → data URI |
|
| 图表 | Chart.js v4.4.0(本地化) | 趋势可视化 |
|
||||||
|
| 密码 | bcrypt(12) | 行业标准 |
|
||||||
|
| 架构 | 分层 + ISP 接口隔离 | 依赖倒置,Controller 不依赖完整 Service |
|
||||||
|
| 重构 | 不向后兼容 | 全新项目,废弃旧代码不做兼容层 |
|
||||||
|
| Lint | golangci-lint v2.12.2(17 个 linter) | CI strict 模式零告警 |
|
||||||
|
|
||||||
---
|
## 五、数据模型一览
|
||||||
|
|
||||||
## 二、解决方案建议
|
| 模型 | BaseModel | 说明 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| User | ✅ | 用户(邮箱/用户名/密码/角色/状态/经验) |
|
||||||
|
| Post | ✅ | 帖子/文章(标题/正文/状态/分类/标签/属性/计数器) |
|
||||||
|
| Comment | ✅ | 评论(含 is_deleted 自定义软删除) |
|
||||||
|
| AuditSubmission | ✅ | 审核记录 |
|
||||||
|
| Notification | ✅ | 通知 |
|
||||||
|
| Announcement | ✅ | 公告 |
|
||||||
|
| Category | ✅ | 文章分类(两级树形) |
|
||||||
|
| Folder | ✅ | 收藏夹 |
|
||||||
|
| FolderItem | ✅ | 收藏记录 |
|
||||||
|
| Tag | ❌ | 标签 |
|
||||||
|
| PostTag | ❌ | 文章-标签关联(联合主键) |
|
||||||
|
| PostLike | ❌ | 点赞(联合主键) |
|
||||||
|
| PostDislike | ❌ | 踩(联合主键) |
|
||||||
|
| UserFollow | ❌ | 关注关系(联合主键) |
|
||||||
|
| CommentMention | ❌ | @提及映射 |
|
||||||
|
| SiteSetting | ❌ | 站点设置(Key-Value) |
|
||||||
|
| EnergyLog / FundLog / PostEnergizeLog | ❌ | 域能/资金流日志 |
|
||||||
|
| UserCheckIn / UserTask | ✅ | 签到/任务 |
|
||||||
|
| DailyExpSummary / DailyLikeSummary | ❌ | 每日统计聚合 |
|
||||||
|
| PostReadLog / PostGuestReadLog | ❌ | 阅读日志 |
|
||||||
|
| CommunityFund | ❌ | 公户余额(单行表) |
|
||||||
|
| Level | ❌ | 等级配置(内存) |
|
||||||
|
|
||||||
### 2.1 PostgreSQL 密码认证(问题一记录)
|
## 六、待做事项
|
||||||
|
|
||||||
**系统信息**:
|
| 优先级 | 功能 | 状态 |
|
||||||
- 分布:Linux Mint
|
|--------|------|------|
|
||||||
- 数据库用户:`metazone`
|
| P2 | B8: BaseModel 主键列名不一致 | ✅ 已完成 |
|
||||||
- 连接方式:TCP `127.0.0.1:5432`(非 Unix socket)
|
| P3 | 集成测试 | ⬜ 暂缓 |
|
||||||
- 认证方式:`scram-sha-256`
|
| P3 | `git tag v0.1.0-rc1` | ⬜ 暂缓 |
|
||||||
- 密码:`MetalabDev2026!`(存储于 `.env` → `DATABASE_PASSWORD`)
|
| — | 黑名单系统 | ⬜ 未来迭代 |
|
||||||
- 数据库:`metalab_dev`
|
| — | 首页推荐算法 | ⬜ 未来迭代 |
|
||||||
|
| — | 模板审计修复 | ⬜ 未来迭代 |
|
||||||
**验证命令**:
|
|
||||||
```bash
|
|
||||||
# 测试连接
|
|
||||||
PGPASSWORD='MetalabDev2026!' psql -h 127.0.0.1 -U metazone -d metalab_dev -c "SELECT current_user, version();"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 .env 文件结构
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 必填
|
|
||||||
DATABASE_PASSWORD=MetalabDev2026!
|
|
||||||
JWT_SECRET=p2BVUsELbny4W4sYNvoyFesyglD2TlPRT+bzpDk8Dq/E2s9tZFzT6jC8N3AvQpzE
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env` 已加入 `.gitignore`,提供 `.env.example` 模板文件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、代码规范新增/修改
|
|
||||||
|
|
||||||
### 3.1 新增规范
|
|
||||||
|
|
||||||
| 编号 | 规范 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| C01 | **`BuildPageData()` 统一模板数据注入** | 所有页面 Controller 必须通过此函数构建 `gin.H`,禁止手动拼装 `IsLoggedIn`/`Username` |
|
|
||||||
| C02 | **Cookie 认证优先** | 使用 HttpOnly Cookie(`mlb_token`)传递 JWT,而非 `Authorization` Header,兼顾安全与 SSR |
|
|
||||||
| C03 | **主键列名:`uid`** | Go 侧字段名 `ID`,GORM 标签 `column:uid`,JSON 序列化 `"uid"` |
|
|
||||||
| C04 | **用户名:10 位随机 `[a-z0-9]`** | 使用 `crypto/rand`(非 `math/rand`),20 次重试去重 |
|
|
||||||
| C05 | **配置 env 注入模式** | `.env` 文件通过 `os.Setenv()` 注入环境变量,Viper 通过 `BindEnv` + `AutomaticEnv` 读取。不再使用 viper 的 `.env` 文件加载 |
|
|
||||||
| C06 | **模板名:相对路径** | `layout/header.html`、`auth/register.html` 等。禁止用纯文件名避免冲突 |
|
|
||||||
|
|
||||||
### 3.2 修改的已有规范
|
|
||||||
|
|
||||||
| 编号 | 变更 | 旧 | 新 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| M01 | 用户标识字段 | `nickname`(非唯一) | `username`(全局唯一,varchar(16)) |
|
|
||||||
| M02 | JSON 序列化字段名 | `"id"` | `"uid"` |
|
|
||||||
| M03 | `BaseModel.ID` GORM 列名 | `id`(默认) | `uid`(`column:uid`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、当前文件清单
|
|
||||||
|
|
||||||
### 后端(Go)
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/
|
|
||||||
├── config/
|
|
||||||
│ └── config.go # Config 结构体 + Load() + loadEnvFile() + DSN()
|
|
||||||
│ # JWTConfig 含 AccessExpire/RefreshExpire/RememberExpire
|
|
||||||
├── model/
|
|
||||||
│ ├── common.go # BaseModel(uid 主键 + 时间戳 + 软删除)
|
|
||||||
│ ├── user.go # User 结构体 + Role/Status 常量
|
|
||||||
│ └── dto.go # RegisterRequest / LoginRequest 含 RememberMe
|
|
||||||
├── repository/
|
|
||||||
│ └── user_repo.go # UserRepo(Create/FindByEmail/FindByID/FindByUsername/ExistsByEmail/ExistsByUsername)
|
|
||||||
├── service/
|
|
||||||
│ └── auth_service.go # AuthService(Login/Register/RefreshAccessToken + buildToken/buildRefreshToken + 8 个错误哨兵)
|
|
||||||
├── controller/
|
|
||||||
│ └── auth_controller.go # AuthController(RegisterPage/LoginPage/Register/Login/Logout/RefreshToken/CheckEmail)
|
|
||||||
├── middleware/
|
|
||||||
│ └── auth.go # AuthRequired/AuthOptional/SetAuthCookies/SetAccessCookie/ClearAuthCookies/parseToken
|
|
||||||
│ # 三个 Cookie:mlb_token / mlb_refresh / mlb_rm
|
|
||||||
├── router/
|
|
||||||
│ └── router.go # Setup() — 路由注册 + 依赖注入链(含 /auth/logout、/auth/refresh)
|
|
||||||
├── common/
|
|
||||||
│ └── helper.go # BuildPageData() — SSR 登录状态注入
|
|
||||||
└── theme/
|
|
||||||
└── loader.go # LoadTemplates() — 相对路径模板加载 + LoadContent()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端(模板 + 静态资源)
|
|
||||||
|
|
||||||
```
|
|
||||||
templates/MetaLab-2026/
|
|
||||||
├── html/
|
|
||||||
│ ├── layout/
|
|
||||||
│ │ ├── header.html # HTML head(title/meta/CSS 引入)
|
|
||||||
│ │ ├── nav.html # 导航栏(已登录显示用户名+退出按钮 / 未登录显示登录按钮)
|
|
||||||
│ │ └── footer.html # 页脚
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ ├── register.html # 注册页(含准则模态框 + 60s 倒计时 + 记住我复选框)
|
|
||||||
│ │ └── login.html # 登录页(简洁表单 + 记住我复选框)
|
|
||||||
│ └── home/
|
|
||||||
│ └── index.html # 首页(Hero 区域 + 欢迎信息)
|
|
||||||
├── static/
|
|
||||||
│ ├── css/
|
|
||||||
│ │ ├── common.css # 全局样式 + NAV 样式(含 .nav-logout)
|
|
||||||
│ │ └── auth.css # 认证页样式(表单、模态框、Toast、.remember-group/.remember-label/.remember-hint)
|
|
||||||
│ └── js/
|
|
||||||
│ ├── common.js # 通用工具(Toast + 登出 + 自动令牌刷新 + mlb_rm 检测)
|
|
||||||
│ ├── register.js # 注册页逻辑(表单验证/AJAX 提交/准则模态框/倒计时/记住我)
|
|
||||||
│ └── login.js # 登录页逻辑(表单验证/AJAX 提交/Toast/记住我)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
|
|
||||||
```
|
|
||||||
config.yaml # 主配置(服务/数据库/JWT 含 remember_expire/BCrypt)
|
|
||||||
.env # 敏感信息(密码/JWT密钥)→ .gitignore
|
|
||||||
.env.example # 敏感信息模板(无真实值)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、待做事项
|
|
||||||
|
|
||||||
按优先级排序:
|
|
||||||
|
|
||||||
| 优先级 | 功能 | 状态 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **P0** | 邮箱验证码 | 待做 | 注册时发送验证码(`auth/verify-email`),预留了路由和接口 |
|
|
||||||
| ~~**P0**~~ | ~~登出功能~~ | ✅ 已完成 | `POST /api/auth/logout` → 清除三个 Cookie + 跳转首页 |
|
|
||||||
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域,需添加帖子列表、话题导航等 |
|
|
||||||
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
|
|
||||||
| **P1** | 用户设置页 | ✅ 已完成 | `/settings/profile`、`/settings/account` — 用户名修改、个性签名、头像上传与裁切 |
|
|
||||||
| **P2** | 管理后台 | 待做 | `/admin` — 用户管理、内容审核、主题切换 |
|
|
||||||
| ~~**P2**~~ | ~~刷新令牌~~ | ✅ 已完成 | 记住我 + access/refresh 双 Token + 自动续期 + 登出 |
|
|
||||||
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` → `MetaLab-2027` 切换 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、约定速查
|
|
||||||
|
|
||||||
| 类别 | 约定 |
|
|
||||||
|---|---|
|
|
||||||
| 数据库主键 | Go: `ID uint`,DB: `uid`(bigint 自增),JSON: `"uid"` |
|
|
||||||
| Access Token Cookie | `mlb_token`,JWT(HS256),15 min,HttpOnly + Secure + SameSite=Lax |
|
|
||||||
| Refresh Token Cookie | `mlb_refresh`,JWT(HS256),30 day(记住我时),HttpOnly + Secure + SameSite=Lax,含 `purpose:"refresh"` 声明 |
|
|
||||||
| 记住我标记 Cookie | `mlb_rm`,值 `"1"`,30 day(记住我时),非 HttpOnly(JS 可读),非凭据 |
|
|
||||||
| 密码哈希 | bcrypt(12),Go 字段 `PasswordHash`,JSON 不暴露 |
|
|
||||||
| 用户名 | 10 位 `[a-z0-9]`,`crypto/rand` 生成,全局唯一 |
|
|
||||||
| 角色 | `user` / `moderator` / `admin`(string,非 bool) |
|
|
||||||
| 配置 | `.env` → `os.Setenv` → viper `BindEnv` + `AutomaticEnv` |
|
|
||||||
| 模板名 | 相对路径:`layout/header.html`、`auth/register.html` |
|
|
||||||
| API 响应 | `common.Ok(ctx, data)` / `common.Error(ctx, code, msg)` |
|
|
||||||
| 页面数据 | `common.BuildPageData(c, extra)` — 自动注入 `IsLoggedIn` / `Username` |
|
|
||||||
| 包依赖方向 | Router → Controller → Service → Repository → Model(不可反向) |
|
|
||||||
| 令牌刷新 | `POST /api/auth/refresh`,前端每 10 min 静默调用 |
|
|
||||||
|
|||||||
13
docs/migrations/002_announcements.sql
Normal file
13
docs/migrations/002_announcements.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
-- 公告表
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
type VARCHAR(20) DEFAULT 'info',
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
start_at TIMESTAMP,
|
||||||
|
end_at TIMESTAMP,
|
||||||
|
sort INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
8
docs/migrations/003_post_attributes.sql
Normal file
8
docs/migrations/003_post_attributes.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- 文章属性扩展
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS visibility VARCHAR(10) DEFAULT 'public';
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type VARCHAR(10) DEFAULT 'original';
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS reprint_source VARCHAR(500) DEFAULT '';
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS declaration VARCHAR(30) DEFAULT '';
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS reprint_prohibited BOOLEAN DEFAULT false;
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS pin_type VARCHAR(10) DEFAULT '';
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMP;
|
||||||
32
docs/migrations/004_categories_tags.sql
Normal file
32
docs/migrations/004_categories_tags.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- 分类表(两级树形)
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
description VARCHAR(200) DEFAULT '',
|
||||||
|
parent_id INT REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
sort INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 预设「未分类」
|
||||||
|
INSERT INTO categories (id, name, slug) VALUES (1, '未分类', 'uncategorized') ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- 标签表
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 文章-标签关联表
|
||||||
|
CREATE TABLE IF NOT EXISTS post_tags (
|
||||||
|
post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (post_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 文章加分类外键
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL;
|
||||||
3
docs/migrations/005_scheduled_posts.sql
Normal file
3
docs/migrations/005_scheduled_posts.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- 定时发布
|
||||||
|
ALTER TABLE posts ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMP;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_scheduled_at ON posts(scheduled_at);
|
||||||
@ -1,231 +0,0 @@
|
|||||||
# 帖子系统代码审计报告
|
|
||||||
|
|
||||||
> 审计日期:2026-05-30
|
|
||||||
> 审计范围:`internal/` 下所有 Post 相关文件(router / controller / service / repository / model)
|
|
||||||
> 审计标准:DRY / KISS / YAGNI / LoD / SOLID + 分层架构规范
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、架构合规性总览
|
|
||||||
|
|
||||||
| 检查项 | 状态 | 说明 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 分层依赖方向 | ✅ | router→controller→service→repo→model,严格单向 |
|
|
||||||
| Controller 接口文件 | ✅ | `controller/interfaces.go` + `admin/interfaces.go` |
|
|
||||||
| Service Repository 接口 | ✅ | `service/repository.go`,8 个方法,无冗余 |
|
|
||||||
| ISP 接口隔离 | ✅ | 前台 `postUseCase` 与后台 `adminPostUseCase` 分离 |
|
|
||||||
| DIP 依赖倒置 | ✅ | Controller→接口, Service→接口 |
|
|
||||||
| 依赖注入 | ✅ | 全部构造函数注入,无硬编码 |
|
|
||||||
| KISS | ✅ | 无过度设计 |
|
|
||||||
| YAGNI | ✅ | 无超前功能 |
|
|
||||||
| LoD | ✅ | 无跨层直接依赖 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、问题清单
|
|
||||||
|
|
||||||
### 🔴 中等问题(4 个)
|
|
||||||
|
|
||||||
#### 问题 1:Repository 方法代码重复(~70%)
|
|
||||||
|
|
||||||
| 项 | 详情 |
|
|
||||||
|----|------|
|
|
||||||
| **文件** | `internal/repository/post_repo.go` |
|
|
||||||
| **位置** | `FindPageable`(行 49-73)vs `FindAdminPageable`(行 76-102) |
|
|
||||||
| **违反原则** | DRY |
|
|
||||||
| **描述** | 两个方法的核心查询逻辑(Table + Select + Joins + keyword LIKE + Count + Offset/Limit + ORDER BY)几乎完全一致,唯一区别是 `FindPageable` 固定过滤 `status = 'approved'`,`FindAdminPageable` 支持可选的 status 参数。两段代码约 70% 重复。 |
|
|
||||||
|
|
||||||
**现状:**
|
|
||||||
```go
|
|
||||||
// FindPageable (行 49-73)
|
|
||||||
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
|
||||||
query := r.db.Table("posts").
|
|
||||||
Select("posts.*, users.username as author_name").
|
|
||||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
|
||||||
Where("posts.deleted_at IS NULL").
|
|
||||||
Where("posts.status = ?", model.PostStatusApproved) // ← 唯一差异
|
|
||||||
|
|
||||||
if keyword != "" { /* LIKE 过滤 */ }
|
|
||||||
// ... Count + Offset/Limit + Find
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindAdminPageable (行 76-102)
|
|
||||||
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
|
||||||
query := r.db.Table("posts").
|
|
||||||
Select("posts.*, users.username as author_name").
|
|
||||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
|
||||||
Where("posts.deleted_at IS NULL")
|
|
||||||
// ← 无硬编码 status,由参数控制
|
|
||||||
|
|
||||||
if keyword != "" { /* LIKE 过滤 */ }
|
|
||||||
if status != "" { query = query.Where("posts.status = ?", status) }
|
|
||||||
// ... Count + Offset/Limit + Find
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议修复:** 提取私有方法 `findPageableCommon`,两个公开方法调用它并传入各自的 WHERE 条件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题 2:Service 方法重复
|
|
||||||
|
|
||||||
| 项 | 详情 |
|
|
||||||
|----|------|
|
|
||||||
| **文件** | `internal/service/post_service.go` |
|
|
||||||
| **位置** | `List`(行 148-156)vs `ListAdmin`(行 159-167) |
|
|
||||||
| **违反原则** | DRY |
|
|
||||||
| **描述** | 两个方法的 nil 检查 + 分页调用模式完全一致,唯一区别是调用的 repo 方法不同。 |
|
|
||||||
|
|
||||||
**现状:**
|
|
||||||
```go
|
|
||||||
// List (行 148-156)
|
|
||||||
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
|
||||||
p.DefaultPagination()
|
|
||||||
posts, total, err := s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
|
|
||||||
if posts == nil { posts = []model.Post{} }
|
|
||||||
return posts, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAdmin (行 159-167)
|
|
||||||
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
|
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
|
||||||
p.DefaultPagination()
|
|
||||||
posts, total, err := s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
|
|
||||||
if posts == nil { posts = []model.Post{} }
|
|
||||||
return posts, total, err
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议修复:** 提取公共的 Pagination 创建 + nil 检查逻辑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题 3:Controller 权限检查重复 6 处
|
|
||||||
|
|
||||||
| 项 | 详情 |
|
|
||||||
|----|------|
|
|
||||||
| **文件** | `internal/controller/post_controller.go` |
|
|
||||||
| **位置** | `ShowPage`(行 82-90)、`EditPage`(行 144-150)、`Update`(行 202-211)、`Delete`(行 239-248)、`Submit`(行 272-281)、`ShowAPI`(行 333-339) |
|
|
||||||
| **违反原则** | DRY |
|
|
||||||
| **描述** | 以下模式在 6 个方法中逐字重复: |
|
|
||||||
|
|
||||||
```go
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !model.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议修复:** 提取私有方法 `getPostAndCheckAccess(id, c)` 返回 `(*model.Post, bool)`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题 4:Model 层职责过重
|
|
||||||
|
|
||||||
| 项 | 详情 |
|
|
||||||
|----|------|
|
|
||||||
| **文件** | `internal/model/post.go` |
|
|
||||||
| **位置** | 行 48-83 |
|
|
||||||
| **违反原则** | SRP(单一职责) |
|
|
||||||
| **描述** | 一个文件混合了多种职责: |
|
|
||||||
|
|
||||||
| 内容 | 类型 | 应在位置 |
|
|
||||||
|------|------|---------|
|
|
||||||
| `Post` struct | 数据模型 | ✅ Model 层 |
|
|
||||||
| `PostStatusDraft` 等常量 | 状态常量 | ✅ Model 层 |
|
|
||||||
| `PostStatusDisplayNames` | 视图映射 | ❌ 应移到 View/Controller 层 |
|
|
||||||
| `PostListResult` | DTO | ❌ 应移到 `model/dto.go` |
|
|
||||||
| `PostCreateRequest` / `PostUpdateRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
|
||||||
| `PostRejectRequest` | 请求 DTO | ❌ 应移到 `model/dto.go` |
|
|
||||||
| `PostListQuery` | 查询 DTO | ❌ 应移到 `model/dto.go` |
|
|
||||||
| `IsPostAccessible` | 业务权限逻辑 | ❌ 应移到 Service 层 |
|
|
||||||
|
|
||||||
**建议修复:** DTO 结构体移到 `model/dto.go`,`PostStatusDisplayNames` 移到 common 或 controller,`IsPostAccessible` 移到 service 层或独立权限模块。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟡 轻微问题(3 个)
|
|
||||||
|
|
||||||
#### 问题 5:工具函数位置不当
|
|
||||||
|
|
||||||
| 项 | 详情 |
|
|
||||||
|----|------|
|
|
||||||
| **文件** | `internal/controller/post_controller.go` |
|
|
||||||
| **位置** | `saveUploadedFile`(行 410-430) |
|
|
||||||
| **违反原则** | SRP / LoD |
|
|
||||||
| **描述** | `saveUploadedFile` 是通用文件 I/O 工具函数(创建目录 + 32KB buffer 循环写入),与 HTTP 处理无关,不依赖 controller 的任何字段。放在 controller 文件中职责不匹配。 |
|
|
||||||
|
|
||||||
**建议修复:** 移到 `internal/common/` 或新建 `internal/util/` 包。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题 6:Controller 层分页重复初始化
|
|
||||||
|
|
||||||
| 项 | 详情 |
|
|
||||||
|----|------|
|
|
||||||
| **文件** | `internal/controller/post_controller.go` |
|
|
||||||
| **位置** | `ListPage`(行 47-49)和 `ListAPI`(行 307-308) |
|
|
||||||
| **违反原则** | DRY |
|
|
||||||
| **描述** | Controller 层为模板渲染再次创建 `Pagination` 对象并调用 `DefaultPagination()`,而 Service 层 `List()` / `ListAdmin()` 内部已经做过一次分页参数校验。Controller 层可以信任 Service 返回的 total 直接计算。 |
|
|
||||||
|
|
||||||
```go
|
|
||||||
// controller 层重复的:
|
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
|
||||||
p.DefaultPagination()
|
|
||||||
// ... 然后调用 service.List(),service 里又做了一遍
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议修复:** Controller 层直接用 `common.Pagination.PageCount(total, pageSize)` 计算总页数,不再重复调用 `DefaultPagination()`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题 7:Admin Restore 错误分类缺失
|
|
||||||
|
|
||||||
| 项 | 详情 |
|
|
||||||
|----|------|
|
|
||||||
| **文件** | `internal/controller/admin/admin_post_controller.go` |
|
|
||||||
| **位置** | `Restore`(行 153-155) |
|
|
||||||
| **违反原则** | 一致性 |
|
|
||||||
| **描述** | 同文件内其他方法(`Approve`/`Reject`/`Unlock`/`Lock`)都对 `ErrPostNotFound` 做 `errors.Is` 精确匹配返回 400,但 `Restore` 没有,所有错误统一返回 500。 |
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Restore — 缺少错误分类
|
|
||||||
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
|
||||||
// ...
|
|
||||||
if err := ctrl.postService.Restore(id); err != nil {
|
|
||||||
// ← 此处应匹配 ErrPostNotFound,返回 400 而非 500
|
|
||||||
common.Error(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议修复:** 添加 `errors.Is(err, common.ErrPostNotFound)` 判断,返回 400。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、亮点
|
|
||||||
|
|
||||||
1. **状态机设计清晰**:Post 状态流转(draft → pending → approved/rejected → locked)每个转换有明确前置条件检查和专用错误哨兵
|
|
||||||
2. **接口设计优秀**:`postUseCase` vs `adminPostUseCase` 的分离体现良好的关注点分离
|
|
||||||
3. **错误哨兵统一管理**:`common/errors.go` 集中定义所有 Post 相关错误
|
|
||||||
4. **审核开关灵活**:通过 `SiteSettings.IsAuditEnabled()` 运行时控制
|
|
||||||
5. **Shortcode 扩展性好**:新增类型只需添加常量和 case 分支
|
|
||||||
6. **软删除 + 恢复**:完善的软删除和恢复机制
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、优先级建议
|
|
||||||
|
|
||||||
| 优先级 | 问题编号 | 原因 |
|
|
||||||
|--------|---------|------|
|
|
||||||
| P0 | — | 无阻塞性问题 |
|
|
||||||
| P1 | 1, 3 | Repository/Controller 重复影响维护成本 |
|
|
||||||
| P2 | 2, 4 | Service 重复 + Model 职责拆分 |
|
|
||||||
| P3 | 5, 6, 7 | 轻微优化项 |
|
|
||||||
@ -1,319 +0,0 @@
|
|||||||
# Vditor 整合 + MD 存储迁移方案
|
|
||||||
|
|
||||||
> **状态:已实施 ✓** `2026-05-30`
|
|
||||||
|
|
||||||
## 决策与结果
|
|
||||||
|
|
||||||
| 决策 | 结论 | 结果 |
|
|
||||||
|------|------|------|
|
|
||||||
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
|
|
||||||
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
|
|
||||||
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
|
|
||||||
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
|
|
||||||
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist,5.8MB(仅必需插件) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 最终数据流
|
|
||||||
|
|
||||||
```
|
|
||||||
编辑器: Vditor(wysiwyg) → vditor.getValue() → MD 纯文本
|
|
||||||
↓
|
|
||||||
存储: 后端直接存 MD(纯文本无 XSS 风险,无需 sanitize)
|
|
||||||
└─ generateExcerpt() 生成 plain text 摘要
|
|
||||||
↓
|
|
||||||
显示: Vditor.preview() 客户端 MD → HTML + github-dark 代码主题
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 变更文件清单(11 个)
|
|
||||||
|
|
||||||
| 文件 | 改动 | 详情 |
|
|
||||||
|------|------|------|
|
|
||||||
| `internal/model/post.go` | 修改 | `Body` → MD 存储,去 `BodyHTML`,加 `Excerpt`(varchar 500) |
|
|
||||||
| `internal/service/post_service.go` | 重写 | 去 bluemonday,加 `generateExcerpt()`(纯 Go regex,无外部依赖) |
|
|
||||||
| `internal/controller/post_controller.go` | 修改 | `UploadImage` → Vditor 原生格式,`ShowPage` 去 BodyHTML,去 `html/template` import |
|
|
||||||
| `internal/common/response.go` | 新增函数 | `VditorUploadOk()` — Vditor 图片上传成功响应 |
|
|
||||||
| `templates/.../posts/new.html` | 重写 | Tiptap → Vditor,去除 importmap/工具栏/语言选择器 |
|
|
||||||
| `templates/.../posts/show.html` | 重写 | Vditor 客户端 MD 渲染,去 highlight.js CDN,去代码标签注入脚本 |
|
|
||||||
| `templates/.../posts/index.html` | 微调 | `Body` 截断 → `Excerpt`(带降级回退) |
|
|
||||||
| `templates/.../editor.js` | 重写 | Vditor 初始化 + CSRF + upload + draft + word count + submit |
|
|
||||||
| `templates/.../posts.css` | 删减 | 去 Tiptap 样式(~150 行)+ 工具栏样式(~40 行),加 Vditor 微调 |
|
|
||||||
| `static/vditor/dist/` | 新增 | 本地 Vditor 文件(5.8MB) |
|
|
||||||
| `go.mod / go.sum` | 清理 | 移除 bluemonday 依赖 |
|
|
||||||
|
|
||||||
**不涉及的路由/中间件/仓储:零改动。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 各层详细实现
|
|
||||||
|
|
||||||
### 1. Model `internal/model/post.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Post struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
|
||||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
|
||||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary
|
|
||||||
// ... 其余字段不变
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Service `internal/service/post_service.go`
|
|
||||||
|
|
||||||
- 删除 `sanitizePolicy`(bluemonday UGC)和 `sanitizeHTML()`
|
|
||||||
- 新增 `generateExcerpt(md string) string`:
|
|
||||||
- 按行逐条去掉 MD 语法(代码块、图片、标题 #、粗体/斜体、引用 >、列表标记等)
|
|
||||||
- 链接保留文字 `[text](url)` → `text`
|
|
||||||
- 按 rune 截断 300 字符,末尾加 `...`
|
|
||||||
- 纯 Go `regexp` 实现,零外部依赖
|
|
||||||
- `Create()` / `Update()` 生成 `Excerpt`,MD 直存无消毒
|
|
||||||
|
|
||||||
### 3. Controller `internal/controller/post_controller.go`
|
|
||||||
|
|
||||||
**UploadImage — Vditor 原生响应格式:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "",
|
|
||||||
"data": {
|
|
||||||
"errFiles": [],
|
|
||||||
"succMap": {
|
|
||||||
"微信截图_2026.png": "/uploads/posts/1_1717071234567.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ShowPage — 去掉 `PostBodyHTML`**,MD 由前端渲染。
|
|
||||||
|
|
||||||
### 4. Common `internal/common/response.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "",
|
|
||||||
"data": gin.H{
|
|
||||||
"errFiles": []string{},
|
|
||||||
"succMap": succMap,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 编辑器 `posts/new.html` + `editor.js`
|
|
||||||
|
|
||||||
**Vditor 初始化关键配置:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
new Vditor('vditor', {
|
|
||||||
mode: 'wysiwyg',
|
|
||||||
cdn: '/static/vditor', // 所有动态加载走本地
|
|
||||||
height: '100%',
|
|
||||||
lang: 'zh_CN',
|
|
||||||
toolbar: [
|
|
||||||
'headings', 'bold', 'italic', 'strike', '|',
|
|
||||||
'line', 'code', 'inline-code', 'link', 'quote', '|',
|
|
||||||
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
|
|
||||||
'upload', 'table', '|',
|
|
||||||
'undo', 'redo', '|',
|
|
||||||
'fullscreen', 'code-theme', '|',
|
|
||||||
'outline', 'preview', 'devtools',
|
|
||||||
],
|
|
||||||
upload: {
|
|
||||||
url: '/api/posts/upload-image',
|
|
||||||
fieldName: 'file',
|
|
||||||
max: 5 * 1024 * 1024,
|
|
||||||
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
|
|
||||||
setHeaders() { // 每次请求重新读取 CSRF
|
|
||||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
return { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
|
||||||
hljs: { style: 'github-dark', enable: true },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**CSRF 处理**:`upload.setHeaders` 为函数,每次上传前动态读取 `meta[name="csrf-token"]`,不会因 token 过期而失败。
|
|
||||||
|
|
||||||
**草稿机制**:
|
|
||||||
- 每 2 秒自动保存 `title` + `md` 到 `localStorage`
|
|
||||||
- 键名:`draft_post_title` / `draft_post_body_md`(与旧 Tiptap HTML 草稿隔离)
|
|
||||||
- 新帖模式下自动恢复草稿
|
|
||||||
|
|
||||||
**表单提交**:`vditor.getValue()` 获取 MD,`POST /api/posts` 或 `PUT /api/posts/:id`
|
|
||||||
|
|
||||||
### 6. 详情页 `posts/show.html`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- MD 内容通过隐藏 textarea 安全传递给 JS -->
|
|
||||||
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
|
|
||||||
|
|
||||||
<!-- Vditor 仅需 method.min.js(47KB),不需要完整编辑器 -->
|
|
||||||
<script src="/static/vditor/dist/method.min.js"></script>
|
|
||||||
<script>
|
|
||||||
Vditor.preview(document.getElementById('postContent'), md, {
|
|
||||||
cdn: '/static/vditor',
|
|
||||||
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
|
|
||||||
hljs: { style: 'github-dark', enable: true },
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. 列表页 `posts/index.html`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Excerpt 优先,降级回退 Body 截断(兼容旧数据) -->
|
|
||||||
<p class="post-card-summary">
|
|
||||||
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. 样式 `posts.css`
|
|
||||||
|
|
||||||
- 移除:`.tiptap` 及所有子选择器(~100 行)、highlight.js 硬编码主题色(~35 行)、手动工具栏样式(~40 行)、JS 注入代码标签 CSS
|
|
||||||
- 保留:`.editor-layout` / `.editor-header` / `.editor-main` / `.editor-pane` / `.editor-statusbar`(布局)
|
|
||||||
- 新增:`#vditor` flex 适配、`.vditor-toolbar` / `.vditor-content` 主题微调
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Vditor 本地文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
templates/MetaLab-2026/static/vditor/dist/
|
|
||||||
├── index.css # 43KB 编辑器 + 预览全部样式
|
|
||||||
├── index.min.js # 292KB 编辑器核心(wysiwyg/sv/ir + 工具栏 + 上传)
|
|
||||||
├── method.min.js # 47KB 独立方法(Vditor.preview 等,详情页用)
|
|
||||||
├── css/content-theme/
|
|
||||||
│ ├── light.css # 内容区明亮主题
|
|
||||||
│ ├── dark.css # 内容区暗色主题
|
|
||||||
│ ├── ant-design.css # Ant Design 主题
|
|
||||||
│ └── wechat.css # 微信主题
|
|
||||||
├── images/
|
|
||||||
│ ├── img-loading.svg # 上传进度指示
|
|
||||||
│ ├── logo.png # Vditor logo
|
|
||||||
│ └── emoji/ # 内置表情包(b3log/octocat/doge 等)
|
|
||||||
├── js/
|
|
||||||
│ ├── highlight.js/
|
|
||||||
│ │ ├── highlight.min.js # 1.4MB 代码高亮核心
|
|
||||||
│ │ ├── third-languages.js # 额外语言支持
|
|
||||||
│ │ └── styles/*.min.css # 60+ 代码主题
|
|
||||||
│ ├── lute/
|
|
||||||
│ │ └── lute.min.js # 3.9MB WASM MD 解析器(Vditor 核心依赖)
|
|
||||||
│ ├── i18n/
|
|
||||||
│ │ └── zh_CN.js # 8KB 中文语言包
|
|
||||||
│ └── icons/
|
|
||||||
│ ├── material.js # Material Design 图标
|
|
||||||
│ └── ant.js # Ant Design 图标
|
|
||||||
```
|
|
||||||
|
|
||||||
**已剔除的插件**(节省 16MB+):
|
|
||||||
|
|
||||||
| 插件 | 大小 | 功能 | 对技术论坛无用 |
|
|
||||||
|------|------|------|---------------|
|
|
||||||
| mathjax | 6.5MB | LaTeX 数学公式 | ❌ |
|
|
||||||
| mermaid | 2.6MB | 流程图/时序图 | ❌ |
|
|
||||||
| graphviz | 2.0MB | 图谱渲染 | ❌ |
|
|
||||||
| katex | 1.5MB | 数学公式引擎 | ❌ |
|
|
||||||
| echarts | 1.0MB | 图表渲染 | ❌ |
|
|
||||||
| markmap | 836KB | 思维导图 | ❌ |
|
|
||||||
| abcjs | 356KB | 五线谱 | ❌ |
|
|
||||||
| plantuml | 32KB | PlantUML | ❌ |
|
|
||||||
| flowchart.js / smiles-drawer | ~400KB | 流程图 / 化学分子式 | ❌ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shortcode 扩展(`[zone:type:params]`)
|
|
||||||
|
|
||||||
### 语法
|
|
||||||
|
|
||||||
在 Markdown 正文中嵌入特殊卡片,语法为 `[zone:类型:参数]`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# 周末活动汇总
|
|
||||||
|
|
||||||
下面是我们本周的推荐活动:
|
|
||||||
|
|
||||||
[zone:event:summer2026]
|
|
||||||
|
|
||||||
更多内容请关注官方动态...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 已注册类型
|
|
||||||
|
|
||||||
| 语法 | 渲染结果 | 状态 |
|
|
||||||
|------|---------|------|
|
|
||||||
| `[zone:event:活动ID]` | 活动卡片(🎪 图标 + ID + 跳转链接) | ✅ 已实现(占位) |
|
|
||||||
| `[zone:game:游戏slug]` | 游戏卡片(🎮 图标 + slug + 跳转链接) | ✅ 已实现(占位) |
|
|
||||||
| `[zone:poll:投票ID]` | 投票组件 | ⏳ 预留 |
|
|
||||||
| `[zone:resource:资源ID]` | 资源推荐卡片 | ⏳ 预留 |
|
|
||||||
|
|
||||||
### 架构
|
|
||||||
|
|
||||||
```
|
|
||||||
MD body "[zone:event:summer2026]"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
ShortcodeService.Process() ← 后端:正则替换 [zone:...] → 占位 <div>
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
VDitor.preview() ← 前端:MD → HTML,占位 div 原样保留
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
renderShortcodes() ← 前端 shortcode.js:扫描 .zone-card,渲染 UI 卡片
|
|
||||||
```
|
|
||||||
|
|
||||||
### 文件清单
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `internal/model/shortcode.go` | 类型常量 + 正则 + DTO |
|
|
||||||
| `internal/service/shortcode_service.go` | 解析器 + 占位 HTML 生成器 |
|
|
||||||
| `internal/controller/post_controller.go` | ShowPage/ShowAPI 调用 Process() |
|
|
||||||
| `internal/router/deps_extra.go` | DI 注入 ShortcodeService |
|
|
||||||
| `templates/.../js/shortcode.js` | 前端卡片渲染器 |
|
|
||||||
| `templates/.../html/posts/show.html` | 引入 shortcode.js + 调用 renderShortcodes() |
|
|
||||||
| `templates/.../static/css/posts.css` | 卡片样式 |
|
|
||||||
| `templates/.../static/js/editor.js` | hint 自动补全提示 |
|
|
||||||
|
|
||||||
### 如何添加新类型
|
|
||||||
|
|
||||||
1. `internal/model/shortcode.go`:注册 `ShortcodeType` 常量
|
|
||||||
2. `internal/service/shortcode_service.go`:在 `renderPlaceholder()` 添加 case
|
|
||||||
3. `templates/.../js/shortcode.js`:在 `cardRenderers` 注册渲染函数
|
|
||||||
4. `templates/.../static/js/editor.js`:在 `hint.extend` 添加补全提示
|
|
||||||
|
|
||||||
### 注意事项
|
|
||||||
|
|
||||||
- 未注册或格式错误的 shortcode **不报错**,原文保留,避免破坏用户内容
|
|
||||||
- 占位 div 结构为 `<div class="zone-card" data-zone-type="..." data-zone-id="...">`
|
|
||||||
- 编辑器输入 `[zone:` 时自动弹出补全列表(Vditor hint.extend)
|
|
||||||
- 后端 API 就绪后,将 `shortcode.js` 中的静态卡片替换为 `fetch()` 动态数据即可
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 不涉及的部分
|
|
||||||
|
|
||||||
- `internal/router/api.go` — 路由不变(`POST /api/posts/upload-image` 端点不变)
|
|
||||||
- `internal/router/frontend.go` — SSR 页面路由不变
|
|
||||||
- `internal/repository/post_repo.go` — 仓储不变(Model 字段变更由 GORM 自动映射)
|
|
||||||
- `internal/middleware/` — CSRF / Auth 中间件不变
|
|
||||||
- `internal/config/` — 配置不变
|
|
||||||
- `cmd/server/main.go` — `Static()` 映射不变(`/static` → `templates/.../static`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **DB Schema**:GORM AutoMigrate 自动添加 `excerpt` 列;旧的 `body_html` 列残留但不影响运行(GORM 不会 DROP,可手动清理)
|
|
||||||
2. **已有帖子**:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
|
|
||||||
3. **CSRF**:`upload.setHeaders` 为函数,每次上传前动态读取最新 token,不受 token 过期影响
|
|
||||||
4. **时序**:Vditor 主文件先加载,lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
|
|
||||||
5. **bluemonday**:已从 `go.mod` 中移除(`go mod tidy` 自动清理)
|
|
||||||
2
go.mod
2
go.mod
@ -1,4 +1,4 @@
|
|||||||
module metazone.cc/metalab
|
module metazone.cc/mce
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
|
|||||||
3
internal/cache/home_cache.go
vendored
3
internal/cache/home_cache.go
vendored
@ -1,10 +1,11 @@
|
|||||||
|
// Package cache 提供首页等热点数据的内存缓存层。
|
||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HomePageCache 首页内存缓存,30 秒 TTL,创建/更新/删除/审核通过时失效
|
// HomePageCache 首页内存缓存,30 秒 TTL,创建/更新/删除/审核通过时失效
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
|
// Package common 提供通用工具函数、错误哨兵和响应辅助方法。
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
// GetGinUser 从 Gin context 中提取当前登录用户的 uid 和 role
|
||||||
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
// 如果用户未登录,ok 返回 false;role 可能为空字符串
|
||||||
@ -18,3 +23,28 @@ func GetGinUser(c *gin.Context) (uid uint, role string, ok bool) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGinExp 从 Gin context 中提取当前用户的经验值
|
||||||
|
// 如果用户未登录或 exp 不存在,ok 返回 false
|
||||||
|
func GetGinExp(c *gin.Context) (exp int, ok bool) {
|
||||||
|
if v, exists := c.Get("exp"); exists {
|
||||||
|
if e, isInt := v.(int); isInt {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseUIDParam 从 URL 路径参数中提取 uint 类型的 UID
|
||||||
|
// 统一 UID 解析逻辑,避免各 Controller 重复 strconv.ParseUint
|
||||||
|
func ParseUIDParam(c *gin.Context, paramName string) (uint, error) {
|
||||||
|
s := c.Param(paramName)
|
||||||
|
if s == "" {
|
||||||
|
return 0, ErrInvalidParam
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidParam
|
||||||
|
}
|
||||||
|
return uint(v), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -15,17 +15,17 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SetSessionCookie 写入会话 ID Cookie
|
// SetSessionCookie 写入会话 ID Cookie
|
||||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
// rememberMe=true → Cookie maxAge=30天,关浏览器后仍保持登录
|
||||||
// rememberMe=false → Cookie session 模式(maxAge=0),关浏览器即清除
|
// rememberMe=false → Cookie maxAge=空闲超时(默认2h),与Redis TTL一致,关浏览器后自动清除
|
||||||
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
|
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config, siteSettings *config.SiteSettings) {
|
||||||
secure := cfg.Server.Mode != "debug"
|
secure := siteSettings.CookieSecure()
|
||||||
c.SetSameSite(http.SameSiteLaxMode)
|
c.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
|
||||||
var maxAge int
|
var maxAge int
|
||||||
if rememberMe {
|
if rememberMe {
|
||||||
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
||||||
} else {
|
} else {
|
||||||
maxAge = 0 // session cookie
|
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
|
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
|
||||||
@ -33,8 +33,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ClearSessionCookie 清除会话 ID Cookie(logout 调用)
|
// ClearSessionCookie 清除会话 ID Cookie(logout 调用)
|
||||||
func ClearSessionCookie(c *gin.Context, cfg *config.Config) {
|
func ClearSessionCookie(c *gin.Context, cfg *config.Config, siteSettings *config.SiteSettings) {
|
||||||
secure := cfg.Server.Mode != "debug"
|
secure := siteSettings.CookieSecure()
|
||||||
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
|
c.SetCookie(SessionCookieName, "", -1, "/", "", secure, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ var (
|
|||||||
ErrEmailExists = errors.New("该邮箱已注册")
|
ErrEmailExists = errors.New("该邮箱已注册")
|
||||||
ErrUsernameTaken = errors.New("该用户名已被占用")
|
ErrUsernameTaken = errors.New("该用户名已被占用")
|
||||||
ErrUsernameInvalid = errors.New("用户名格式无效(支持中英文、数字、下划线、连字符,1-16个字符)")
|
ErrUsernameInvalid = errors.New("用户名格式无效(支持中英文、数字、下划线、连字符,1-16个字符)")
|
||||||
ErrBioTooLong = errors.New("个性签名不能超过 128 个字符")
|
ErrBioTooLong = errors.New("个性签名不能超过 128 个字符")
|
||||||
ErrInvalidCred = errors.New("邮箱或密码错误")
|
ErrInvalidCred = errors.New("邮箱或密码错误")
|
||||||
ErrUserNotFound = errors.New("用户不存在")
|
ErrUserNotFound = errors.New("用户不存在")
|
||||||
ErrUserBanned = errors.New("该账号已被封禁")
|
ErrUserBanned = errors.New("该账号已被封禁")
|
||||||
@ -24,10 +24,10 @@ var (
|
|||||||
ErrPermissionDenied = errors.New("权限不足")
|
ErrPermissionDenied = errors.New("权限不足")
|
||||||
|
|
||||||
// 审核相关
|
// 审核相关
|
||||||
ErrAuditNotFound = errors.New("审核记录不存在")
|
ErrAuditNotFound = errors.New("审核记录不存在")
|
||||||
ErrAuditNotPending = errors.New("该审核记录已处理")
|
ErrAuditNotPending = errors.New("该审核记录已处理")
|
||||||
ErrAuditDisabled = errors.New("审核功能未开启")
|
ErrAuditDisabled = errors.New("审核功能未开启")
|
||||||
ErrAuditTypeOff = errors.New("该类型审核未开启")
|
ErrAuditTypeOff = errors.New("该类型审核未开启")
|
||||||
|
|
||||||
// 密码 & 注销相关
|
// 密码 & 注销相关
|
||||||
ErrIncorrectPassword = errors.New("当前密码错误")
|
ErrIncorrectPassword = errors.New("当前密码错误")
|
||||||
@ -38,23 +38,27 @@ var (
|
|||||||
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
|
ErrMaintenanceMode = errors.New("社区正在维护中,仅站长可登录")
|
||||||
|
|
||||||
// 帖子相关
|
// 帖子相关
|
||||||
ErrPostNotFound = errors.New("帖子不存在")
|
ErrPostNotFound = errors.New("帖子不存在")
|
||||||
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
|
||||||
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
ErrScheduledTooSoon = errors.New("定时发布时间至少需晚于当前 30 分钟")
|
||||||
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
|
||||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
|
||||||
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
||||||
|
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||||
|
|
||||||
// 域能相关
|
// 域能相关
|
||||||
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
|
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
|
||||||
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
|
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
|
||||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||||
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
||||||
|
|
||||||
// 评论相关
|
// 评论相关
|
||||||
ErrCommentNotFound = errors.New("评论不存在")
|
ErrCommentNotFound = errors.New("评论不存在")
|
||||||
ErrCommentEmpty = errors.New("评论内容不能为空")
|
ErrCommentEmpty = errors.New("评论内容不能为空")
|
||||||
|
|
||||||
|
// 通用参数校验
|
||||||
|
ErrInvalidParam = errors.New("参数无效")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"mime/multipart"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SaveUploadedFile 将 multipart.File 内容写入目标路径
|
|
||||||
func SaveUploadedFile(file multipart.File, dst string) error {
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
for {
|
|
||||||
n, err := file.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
|
||||||
return writeErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -11,30 +11,41 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assetHashes 静态资源文件哈希表(URL 路径 → 短哈希,文件不变哈希不变)
|
// assetHashes 静态资源文件哈希表(URL 路径 → 短哈希,文件不变哈希不变)
|
||||||
var assetHashes map[string]string
|
var (
|
||||||
|
assetHashes map[string]string
|
||||||
|
assetMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
|
// ComputeAssetHashes 遍历 static 目录,计算每个文件的 CRC32 作为版本号
|
||||||
|
// 构建本地 map 后原子替换,避免并发读写冲突
|
||||||
func ComputeAssetHashes(templateDir string) {
|
func ComputeAssetHashes(templateDir string) {
|
||||||
assetHashes = make(map[string]string)
|
mu := make(map[string]string)
|
||||||
// 前端主题:templates/MetaLab-2026/static → URL /static/
|
// 前端主题:templates/MetaLab-2026/static → URL /static/
|
||||||
walkStaticDir(filepath.Join(templateDir, "static"), "/static")
|
walkStaticDirInto(mu, filepath.Join(templateDir, "static"), "/static")
|
||||||
// 共享资源:templates/shared/static → URL /shared/static/
|
// 共享资源:templates/shared/static → URL /shared/static/
|
||||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
|
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "shared", "static"), "/shared/static")
|
||||||
// 管理后台:templates/admin/static → URL /admin/static/
|
// 管理后台:templates/admin/static → URL /admin/static/
|
||||||
walkStaticDir(filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
|
walkStaticDirInto(mu, filepath.Join(filepath.Dir(templateDir), "admin", "static"), "/admin/static")
|
||||||
log.Printf("[AssetHashes] computed %d file hashes", len(assetHashes))
|
|
||||||
|
assetMu.Lock()
|
||||||
|
assetHashes = mu
|
||||||
|
assetMu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[AssetHashes] computed %d file hashes", len(mu))
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkStaticDir 遍历目录,计算每个 .js/.css 的 CRC32,存入 assetHashes
|
// walkStaticDirInto 遍历目录,计算每个 .js/.css 的 CRC32,存入目标 map
|
||||||
func walkStaticDir(dir, urlPrefix string) {
|
func walkStaticDirInto(dst map[string]string, dir, urlPrefix string) {
|
||||||
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil || d.IsDir() {
|
if err != nil || d.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -42,7 +53,7 @@ func walkStaticDir(dir, urlPrefix string) {
|
|||||||
if ext != ".js" && ext != ".css" {
|
if ext != ".js" && ext != ".css" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path) //nolint:gosec // path 来自 filepath.WalkDir 遍历,非用户输入
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -51,14 +62,17 @@ func walkStaticDir(dir, urlPrefix string) {
|
|||||||
// 生成 URL 路径:urlPrefix + 相对于 dir 的子路径
|
// 生成 URL 路径:urlPrefix + 相对于 dir 的子路径
|
||||||
rel, _ := filepath.Rel(dir, path)
|
rel, _ := filepath.Rel(dir, path)
|
||||||
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
|
urlPath := urlPrefix + "/" + filepath.ToSlash(rel)
|
||||||
assetHashes[urlPath] = shortHash
|
dst[urlPath] = shortHash
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssetV 返回指定静态资源的哈希版本号(模板函数)
|
// AssetV 返回指定静态资源的哈希版本号(模板函数,并发安全)
|
||||||
func AssetV(path string) string {
|
func AssetV(path string) string {
|
||||||
if h, ok := assetHashes[path]; ok {
|
assetMu.RLock()
|
||||||
|
h, ok := assetHashes[path]
|
||||||
|
assetMu.RUnlock()
|
||||||
|
if ok {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
return "0"
|
return "0"
|
||||||
@ -159,10 +173,47 @@ func RedirectToLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// injectSiteInfo 从 context 注入站点展示信息
|
// injectSiteInfo 从 context 注入站点展示信息
|
||||||
func injectSiteInfo(c *gin.Context, data gin.H) {
|
func injectSiteInfo(c *gin.Context, data gin.H) {
|
||||||
|
if siteName, exists := c.Get("site_name"); exists {
|
||||||
|
data["SiteName"] = siteName
|
||||||
|
}
|
||||||
|
if siteTagline, exists := c.Get("site_tagline"); exists {
|
||||||
|
data["SiteTagline"] = siteTagline
|
||||||
|
}
|
||||||
|
if contactEmail, exists := c.Get("site_contact_email"); exists {
|
||||||
|
data["ContactEmail"] = contactEmail
|
||||||
|
}
|
||||||
if framework, exists := c.Get("site_framework"); exists {
|
if framework, exists := c.Get("site_framework"); exists {
|
||||||
data["Framework"] = framework
|
data["Framework"] = framework
|
||||||
}
|
}
|
||||||
if copyrightStart, exists := c.Get("site_copyright_start"); exists {
|
if copyrightStart, exists := c.Get("site_copyright_start"); exists {
|
||||||
data["CopyrightStart"] = copyrightStart
|
data["CopyrightStart"] = copyrightStart
|
||||||
}
|
}
|
||||||
|
if operatorName, exists := c.Get("site_operator_name"); exists {
|
||||||
|
data["OperatorName"] = operatorName
|
||||||
|
}
|
||||||
|
if icpNumber, exists := c.Get("site_icp_number"); exists {
|
||||||
|
data["ICPNumber"] = icpNumber
|
||||||
|
}
|
||||||
|
if icpLicense, exists := c.Get("site_icp_license"); exists {
|
||||||
|
data["ICPLicense"] = icpLicense
|
||||||
|
}
|
||||||
|
if policeNumber, exists := c.Get("site_police_number"); exists {
|
||||||
|
data["PoliceNumber"] = policeNumber
|
||||||
|
}
|
||||||
|
if wenwangwen, exists := c.Get("site_wenwangwen"); exists {
|
||||||
|
data["WenWangWen"] = wenwangwen
|
||||||
|
}
|
||||||
|
if algorithmFiling, exists := c.Get("site_algorithm_filing"); exists {
|
||||||
|
data["AlgorithmFiling"] = algorithmFiling
|
||||||
|
}
|
||||||
|
if privacyURL, exists := c.Get("site_privacy_url"); exists {
|
||||||
|
data["PrivacyURL"] = privacyURL
|
||||||
|
}
|
||||||
|
if termsURL, exists := c.Get("site_terms_url"); exists {
|
||||||
|
data["TermsURL"] = termsURL
|
||||||
|
}
|
||||||
|
if guidelinesTimer, exists := c.Get("registration_guidelines_timer"); exists {
|
||||||
|
data["GuidelinesTimer"] = guidelinesTimer
|
||||||
|
}
|
||||||
|
data["CurrentYear"] = time.Now().Year()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,5 +57,5 @@ func PageCount(total int64, pageSize int) int {
|
|||||||
return int((total + int64(pageSize) - 1) / int64(pageSize))
|
return int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 固定时间格式,前后端统一
|
// TimeFormat 固定时间格式,前后端统一。
|
||||||
const TimeFormat = time.RFC3339
|
const TimeFormat = time.RFC3339
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import "metazone.cc/metalab/internal/model"
|
import "metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
// PostStatusDisplayNames 帖子状态 → 中文名称映射(供模板渲染使用)
|
// PostStatusDisplayNames 帖子状态 → 中文名称映射(供模板渲染使用)
|
||||||
var PostStatusDisplayNames = map[string]string{
|
var PostStatusDisplayNames = map[string]string{
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRedisClient 创建 Redis 客户端并尝试验证连接
|
// NewRedisClient 创建 Redis 客户端并尝试验证连接
|
||||||
@ -20,8 +20,8 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
|
|||||||
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s),配合 FallbackStore 快速降级
|
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s),配合 FallbackStore 快速降级
|
||||||
ReadTimeout: 1 * time.Second,
|
ReadTimeout: 1 * time.Second,
|
||||||
WriteTimeout: 1 * time.Second,
|
WriteTimeout: 1 * time.Second,
|
||||||
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
|
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
|
||||||
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS)
|
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS)
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
@ -29,7 +29,7 @@ func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
|
|||||||
|
|
||||||
if err := client.Ping(ctx).Err(); err != nil {
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
log.Printf("[Redis] 连接失败: %v,将降级为内存存储", err)
|
log.Printf("[Redis] 连接失败: %v,将降级为内存存储", err)
|
||||||
return client, fmt.Errorf("Redis 连接失败: %w", err)
|
return client, fmt.Errorf("redis 连接失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 统一 JSON 响应
|
// 统一 JSON 响应
|
||||||
|
|||||||
@ -7,8 +7,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// usernameChars 随机用户名字符集(小写字母 + 数字)
|
// usernameChars 随机用户名字符集(小写字母 + 数字)
|
||||||
const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
const (
|
||||||
const usernameLen = 10
|
usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
usernameLen = 10
|
||||||
|
)
|
||||||
|
|
||||||
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository)
|
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository)
|
||||||
type UsernameChecker interface {
|
type UsernameChecker interface {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package config 管理应用配置的加载、解析和运行时访问。
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -22,15 +23,17 @@ type Config struct {
|
|||||||
|
|
||||||
// AuditConfig 审核系统配置(config.yaml 提供默认值,站点设置可运行时覆盖)
|
// AuditConfig 审核系统配置(config.yaml 提供默认值,站点设置可运行时覆盖)
|
||||||
type AuditConfig struct {
|
type AuditConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
UsernameAudit bool `mapstructure:"username_audit"`
|
UsernameAudit bool `mapstructure:"username_audit"`
|
||||||
AvatarAudit bool `mapstructure:"avatar_audit"`
|
AvatarAudit bool `mapstructure:"avatar_audit"`
|
||||||
BioAudit bool `mapstructure:"bio_audit"`
|
BioAudit bool `mapstructure:"bio_audit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port string `mapstructure:"port"`
|
Port string `mapstructure:"port"`
|
||||||
Mode string `mapstructure:"mode"`
|
Mode string `mapstructure:"mode"`
|
||||||
|
CookieSecure bool `mapstructure:"cookie_secure"`
|
||||||
|
Timezone string `mapstructure:"timezone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@ -64,9 +67,9 @@ type BcryptConfig struct {
|
|||||||
|
|
||||||
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml)
|
// RolesConfig 角色配置(可扩展,新增角色只需改 config.yaml)
|
||||||
type RolesConfig struct {
|
type RolesConfig struct {
|
||||||
Levels map[string]int `mapstructure:"levels"`
|
Levels map[string]int `mapstructure:"levels"`
|
||||||
Names map[string]string `mapstructure:"names"`
|
Names map[string]string `mapstructure:"names"`
|
||||||
Permissions RolesPermissions `mapstructure:"permissions"`
|
Permissions RolesPermissions `mapstructure:"permissions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RolesPermissions 角色间操作权限
|
// RolesPermissions 角色间操作权限
|
||||||
@ -74,7 +77,7 @@ type RolesPermissions struct {
|
|||||||
OperableRoles map[string][]string `mapstructure:"operable_roles"`
|
OperableRoles map[string][]string `mapstructure:"operable_roles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局配置实例(初始化后只读)
|
// App 全局配置实例(初始化后只读)。
|
||||||
var App *Config
|
var App *Config
|
||||||
|
|
||||||
// Load 加载配置:config.yaml → .env 覆盖
|
// Load 加载配置:config.yaml → .env 覆盖
|
||||||
@ -110,11 +113,11 @@ func Load(configPath string) *Config {
|
|||||||
|
|
||||||
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
|
// loadEnvFile 读取 .env 文件并设置环境变量(仅当环境变量未设置时)
|
||||||
func loadEnvFile(path string) {
|
func loadEnvFile(path string) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path) //nolint:gosec // path 为内部 .env 文件路径
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return // .env 不存在,跳过
|
return // .env 不存在,跳过
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@ -129,15 +132,29 @@ func loadEnvFile(path string) {
|
|||||||
key := strings.TrimSpace(parts[0])
|
key := strings.TrimSpace(parts[0])
|
||||||
val := strings.TrimSpace(parts[1])
|
val := strings.TrimSpace(parts[1])
|
||||||
if os.Getenv(key) == "" {
|
if os.Getenv(key) == "" {
|
||||||
os.Setenv(key, val)
|
_ = os.Setenv(key, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindEnvOverride 将环境变量映射到 config 的嵌套键
|
// bindEnvOverride 将环境变量映射到 config 的嵌套键(.env 覆盖 config.yaml 默认值)
|
||||||
func bindEnvOverride(v *viper.Viper) {
|
func bindEnvOverride(v *viper.Viper) {
|
||||||
|
// 数据库
|
||||||
|
_ = v.BindEnv("database.host", "DATABASE_HOST")
|
||||||
|
_ = v.BindEnv("database.port", "DATABASE_PORT")
|
||||||
|
_ = v.BindEnv("database.user", "DATABASE_USER")
|
||||||
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
||||||
|
_ = v.BindEnv("database.dbname", "DATABASE_DBNAME")
|
||||||
|
_ = v.BindEnv("database.sslmode", "DATABASE_SSLMODE")
|
||||||
|
// Redis
|
||||||
|
_ = v.BindEnv("redis.enabled", "REDIS_ENABLED")
|
||||||
|
_ = v.BindEnv("redis.host", "REDIS_HOST")
|
||||||
|
_ = v.BindEnv("redis.port", "REDIS_PORT")
|
||||||
_ = v.BindEnv("redis.password", "REDIS_PASSWORD")
|
_ = v.BindEnv("redis.password", "REDIS_PASSWORD")
|
||||||
|
_ = v.BindEnv("redis.db", "REDIS_DB")
|
||||||
|
// 服务
|
||||||
|
_ = v.BindEnv("server.port", "SERVER_PORT")
|
||||||
|
_ = v.BindEnv("server.mode", "SERVER_MODE")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
|
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SiteSettings 站点设置管理器(DB 持久化 + 内存缓存,不重启生效)
|
// SiteSettings 站点设置管理器(DB 持久化 + 内存缓存,不重启生效)
|
||||||
@ -69,15 +71,22 @@ func (s *SiteSettings) GetBool(key string, defaultVal bool) bool {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set 写入设置(同步写 DB + 更新内存)
|
// Set 写入设置(同步写 DB + 更新内存,使用 UPSERT 兼容首次保存)
|
||||||
func (s *SiteSettings) Set(key, value string) error {
|
func (s *SiteSettings) Set(key, value string) error {
|
||||||
entry := model.SiteSetting{Key: key, Value: value}
|
entry := model.SiteSetting{Key: key, Value: value}
|
||||||
if err := s.db.Save(&entry).Error; err != nil {
|
result := s.db.Clauses(clause.OnConflict{
|
||||||
return err
|
Columns: []clause.Column{{Name: "key"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||||
|
}).Create(&entry)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[SiteSettings] Set FAIL key=%s value=%s err=%v", key, value, result.Error)
|
||||||
|
return result.Error
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
oldVal, existed := s.data[key]
|
||||||
s.data[key] = value
|
s.data[key] = value
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
log.Printf("[SiteSettings] Set OK key=%s old=%q new=%q existed=%v rows=%d", key, oldVal, value, existed, result.RowsAffected)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,24 +121,86 @@ func (s *SiteSettings) IsAuditTypeEnabled(auditType string, defaultVal bool) boo
|
|||||||
|
|
||||||
// SiteInfoDefaults 站点展示信息的默认值
|
// SiteInfoDefaults 站点展示信息的默认值
|
||||||
type SiteInfoDefaults struct {
|
type SiteInfoDefaults struct {
|
||||||
Framework string // 附加标识,默认 "Framework: MetaZone"
|
SiteName string // 站点名称,默认 "MetaLab"
|
||||||
CopyrightStart string // 版权起始年份,默认 "2024"
|
SiteTagline string // 站点标语,默认 "下一代开发者社区"
|
||||||
|
ContactEmail string // 联系/申诉邮箱,默认 "metazone@foxmail.com"
|
||||||
|
Framework string // 附加标识,默认 "Framework: MetaGenesis"
|
||||||
|
CopyrightStart string // 版权起始年份,默认 "2024"
|
||||||
|
OperatorName string // 运营主体
|
||||||
|
ICPNumber string // ICP 备案(HTML,含链接)
|
||||||
|
ICPLicense string // ICP 经营许可证(HTML)
|
||||||
|
PoliceNumber string // 公安备案(HTML)
|
||||||
|
WenWangWen string // 文网文(HTML)
|
||||||
|
AlgorithmFiling string // 算法备案(HTML)
|
||||||
|
PrivacyURL string // 隐私政策(HTML)
|
||||||
|
TermsURL string // 服务条款(HTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteInfo 获取站点展示信息(优先 DB 设置,fallback 默认值)
|
// SiteInfo 获取站点展示信息(优先 DB 设置,fallback 默认值)
|
||||||
func (s *SiteSettings) SiteInfo() SiteInfoDefaults {
|
func (s *SiteSettings) SiteInfo() SiteInfoDefaults {
|
||||||
return SiteInfoDefaults{
|
return SiteInfoDefaults{
|
||||||
Framework: s.Get("site.framework", "Framework: MetaZone"),
|
SiteName: s.Get("site.name", "MetaLab"),
|
||||||
CopyrightStart: s.Get("site.copyright_start", "2024"),
|
SiteTagline: s.Get("site.tagline", "下一代开发者社区"),
|
||||||
|
ContactEmail: s.Get("site.contact_email", "metazone@foxmail.com"),
|
||||||
|
Framework: s.Get("site.framework", "Framework: MetaGenesis"),
|
||||||
|
CopyrightStart: s.Get("site.copyright_start", "2024"),
|
||||||
|
OperatorName: s.Get("site.operator_name", ""),
|
||||||
|
ICPNumber: s.Get("site.icp_number", ""),
|
||||||
|
ICPLicense: s.Get("site.icp_license", ""),
|
||||||
|
PoliceNumber: s.Get("site.police_number", ""),
|
||||||
|
WenWangWen: s.Get("site.wenwangwen", ""),
|
||||||
|
AlgorithmFiling: s.Get("site.algorithm_filing", ""),
|
||||||
|
PrivacyURL: s.Get("site.privacy_url", ""),
|
||||||
|
TermsURL: s.Get("site.terms_url", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CookieSecure 返回 cookie_secure 配置值(优先站点设置,fallback config.yaml)
|
||||||
|
func (s *SiteSettings) CookieSecure() bool {
|
||||||
|
return s.GetBool("site.cookie_secure", App.Server.CookieSecure)
|
||||||
|
}
|
||||||
|
|
||||||
// IsRegistrationEnabled 是否允许新用户注册
|
// IsRegistrationEnabled 是否允许新用户注册
|
||||||
func (s *SiteSettings) IsRegistrationEnabled() bool {
|
func (s *SiteSettings) IsRegistrationEnabled() bool {
|
||||||
return s.GetBool("registration.enabled", true)
|
return s.GetBool("registration.enabled", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShowGuidelinesModal 注册时是否弹窗显示社区准则
|
||||||
|
func (s *SiteSettings) ShowGuidelinesModal() bool {
|
||||||
|
return s.GetBool("registration.show_guidelines", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceGuidelines 是否强制阅读准则(需要倒计时+滚动到底部)
|
||||||
|
func (s *SiteSettings) ForceGuidelines() bool {
|
||||||
|
return s.GetBool("registration.force_guidelines", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordStrength 密码强度级别
|
||||||
|
func (s *SiteSettings) PasswordStrength() string {
|
||||||
|
level := s.Get("password.strength", "medium")
|
||||||
|
switch level {
|
||||||
|
case "low", "medium", "high", "very_high":
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
return "medium"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuidelinesTimer 强制阅读倒计时秒数
|
||||||
|
func (s *SiteSettings) GuidelinesTimer() int {
|
||||||
|
val := s.Get("registration.guidelines_timer", "60")
|
||||||
|
n, err := strconv.Atoi(val)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return 60
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// IsMaintenanceEnabled 是否处于维护模式(维护期间仅站长可登录和访问)
|
// IsMaintenanceEnabled 是否处于维护模式(维护期间仅站长可登录和访问)
|
||||||
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
||||||
return s.GetBool("maintenance.enabled", false)
|
return s.GetBool("maintenance.enabled", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeName 当前选用的主题目录名,默认 "MetaLab-2026"
|
||||||
|
func (s *SiteSettings) ThemeName() string {
|
||||||
|
return s.Get("site.theme", "MetaLab-2026")
|
||||||
|
}
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
|
// Package admin 提供管理后台的 HTTP 控制器:用户管理、审核、站点设置等。
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -104,9 +106,10 @@ func (ac *AdminController) UpdateUserStatus(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
action := "封禁"
|
action := "封禁"
|
||||||
if req.Status == model.StatusActive {
|
switch req.Status {
|
||||||
|
case model.StatusActive:
|
||||||
action = "解封"
|
action = "解封"
|
||||||
} else if req.Status == model.StatusLocked {
|
case model.StatusLocked:
|
||||||
action = "已锁定"
|
action = "已锁定"
|
||||||
}
|
}
|
||||||
common.OkMessage(c, action+"成功")
|
common.OkMessage(c, action+"成功")
|
||||||
@ -131,23 +134,18 @@ func (ac *AdminController) ResetToken(c *gin.Context) {
|
|||||||
|
|
||||||
// parseUIDParam 从 URL 路径参数解析 uid
|
// parseUIDParam 从 URL 路径参数解析 uid
|
||||||
func parseUIDParam(c *gin.Context) (uint, error) {
|
func parseUIDParam(c *gin.Context) (uint, error) {
|
||||||
uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
return common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return uint(uid), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleServiceError 统一处理 service 层返回的错误
|
// handleServiceError 统一处理 service 层返回的错误
|
||||||
func handleServiceError(c *gin.Context, err error) {
|
func handleServiceError(c *gin.Context, err error) {
|
||||||
switch err {
|
if errors.Is(err, common.ErrPermissionDenied) {
|
||||||
case common.ErrPermissionDenied:
|
|
||||||
common.Error(c, http.StatusForbidden, "权限不足")
|
common.Error(c, http.StatusForbidden, "权限不足")
|
||||||
case common.ErrUserNotFound:
|
} else if errors.Is(err, common.ErrUserNotFound) {
|
||||||
common.Error(c, http.StatusNotFound, "用户不存在")
|
common.Error(c, http.StatusNotFound, "用户不存在")
|
||||||
case common.ErrEmailExists:
|
} else if errors.Is(err, common.ErrEmailExists) {
|
||||||
common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁")
|
common.Error(c, http.StatusConflict, "该邮箱已被其他用户注册,无法解锁")
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -44,7 +45,7 @@ func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
|
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
|
||||||
if err == common.ErrInsufficientFund {
|
if errors.Is(err, common.ErrInsufficientFund) {
|
||||||
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
|
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -99,7 +100,7 @@ func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Ok(c, gin.H{
|
common.Ok(c, gin.H{
|
||||||
"balance": balance,
|
"balance": balance,
|
||||||
"balance_display": float64(balance) / 10,
|
"balance_display": float64(balance) / 10,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -103,6 +103,44 @@ func (ctrl *AdminPostController) Unlock(c *gin.Context) {
|
|||||||
common.OkMessage(c, "已解锁")
|
common.OkMessage(c, "已解锁")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin 置顶文章
|
||||||
|
func (ctrl *AdminPostController) Pin(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
PinType string `json:"pin_type" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || (req.PinType != model.PinTypeCategory && req.PinType != model.PinTypeGlobal) {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的置顶类型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Pin(uint(id), req.PinType); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "置顶失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "已置顶")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpin 取消置顶
|
||||||
|
func (ctrl *AdminPostController) Unpin(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Unpin(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "取消置顶失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "已取消置顶")
|
||||||
|
}
|
||||||
|
|
||||||
// Restore 恢复软删除
|
// Restore 恢复软删除
|
||||||
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
102
internal/controller/admin/announcement_controller.go
Normal file
102
internal/controller/admin/announcement_controller.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/mce/internal/common"
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnnouncementController 公告管理控制器
|
||||||
|
type AnnouncementController struct {
|
||||||
|
svc announcementUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type announcementUseCase interface {
|
||||||
|
Create(a *model.Announcement) error
|
||||||
|
Update(a *model.Announcement) error
|
||||||
|
Delete(id uint) error
|
||||||
|
List(offset, limit int) ([]model.Announcement, int64, error)
|
||||||
|
ListActive() ([]model.Announcement, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnnouncementController 构造函数
|
||||||
|
func NewAnnouncementController(svc announcementUseCase) *AnnouncementController {
|
||||||
|
return &AnnouncementController{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 公告管理页面
|
||||||
|
func (ac *AnnouncementController) Page(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/announcements/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "公告管理",
|
||||||
|
"ExtraCSS": "/admin/static/css/announcements.css",
|
||||||
|
"ExtraJS": "/admin/static/js/announcements.js",
|
||||||
|
"Types": service.AnnouncementTypes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 公告列表 API
|
||||||
|
func (ac *AnnouncementController) List(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
list, total, err := ac.svc.List(offset, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{"list": list, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建公告 API
|
||||||
|
func (ac *AnnouncementController) Create(c *gin.Context) {
|
||||||
|
var req model.Announcement
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ac.svc.Create(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "创建失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "公告已创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新公告 API
|
||||||
|
func (ac *AnnouncementController) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.Announcement
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = uint(id)
|
||||||
|
if err := ac.svc.Update(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "更新失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "公告已更新")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除公告 API
|
||||||
|
func (ac *AnnouncementController) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ac.svc.Delete(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "公告已删除")
|
||||||
|
}
|
||||||
@ -1,11 +1,12 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -125,16 +126,15 @@ func parseIDParam(s string) (uint, error) {
|
|||||||
|
|
||||||
// handleAuditError 统一处理审核接口的 service 层错误
|
// handleAuditError 统一处理审核接口的 service 层错误
|
||||||
func handleAuditError(c *gin.Context, err error) {
|
func handleAuditError(c *gin.Context, err error) {
|
||||||
switch err {
|
if errors.Is(err, common.ErrAuditNotFound) {
|
||||||
case common.ErrAuditNotFound:
|
|
||||||
common.Error(c, http.StatusNotFound, "审核记录不存在")
|
common.Error(c, http.StatusNotFound, "审核记录不存在")
|
||||||
case common.ErrAuditNotPending:
|
} else if errors.Is(err, common.ErrAuditNotPending) {
|
||||||
common.Error(c, http.StatusBadRequest, "该审核记录已处理")
|
common.Error(c, http.StatusBadRequest, "该审核记录已处理")
|
||||||
case common.ErrUserNotFound:
|
} else if errors.Is(err, common.ErrUserNotFound) {
|
||||||
common.Error(c, http.StatusNotFound, "用户不存在")
|
common.Error(c, http.StatusNotFound, "用户不存在")
|
||||||
case common.ErrUsernameTaken:
|
} else if errors.Is(err, common.ErrUsernameTaken) {
|
||||||
common.Error(c, http.StatusConflict, "该用户名已被其他用户占用,审批失败")
|
common.Error(c, http.StatusConflict, "该用户名已被其他用户占用,审批失败")
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
internal/controller/admin/category_controller.go
Normal file
100
internal/controller/admin/category_controller.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/mce/internal/common"
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CategoryController 分类管理控制器
|
||||||
|
type CategoryController struct {
|
||||||
|
svc categoryUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoryUseCase interface {
|
||||||
|
Create(c *model.Category) error
|
||||||
|
Update(c *model.Category) error
|
||||||
|
Delete(id uint) error
|
||||||
|
Tree() ([]model.Category, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCategoryController 构造函数
|
||||||
|
func NewCategoryController(svc categoryUseCase) *CategoryController {
|
||||||
|
return &CategoryController{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 分类管理页面
|
||||||
|
func (cc *CategoryController) Page(c *gin.Context) {
|
||||||
|
tree, _ := cc.svc.Tree()
|
||||||
|
c.HTML(http.StatusOK, "admin/categories/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "分类管理",
|
||||||
|
"ExtraCSS": "/admin/static/css/categories.css",
|
||||||
|
"ExtraJS": "/admin/static/js/categories.js",
|
||||||
|
"Tree": tree,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分类列表 API
|
||||||
|
func (cc *CategoryController) List(c *gin.Context) {
|
||||||
|
tree, err := cc.svc.Tree()
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tree == nil {
|
||||||
|
tree = []model.Category{}
|
||||||
|
}
|
||||||
|
common.Ok(c, tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建分类
|
||||||
|
func (cc *CategoryController) Create(c *gin.Context) {
|
||||||
|
var req model.Category
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cc.svc.Create(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "创建失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "分类已创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新分类
|
||||||
|
func (cc *CategoryController) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.Category
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = uint(id)
|
||||||
|
if err := cc.svc.Update(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "更新失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "分类已更新")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除分类
|
||||||
|
func (cc *CategoryController) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cc.svc.Delete(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "分类已删除")
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/mce/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:6 个方法)
|
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:6 个方法)
|
||||||
@ -23,12 +23,13 @@ type auditUseCase interface {
|
|||||||
Reject(reviewerID uint, submissionID uint, reason string) error
|
Reject(reviewerID uint, submissionID uint, reason string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:4 个方法)
|
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:5 个方法)
|
||||||
type siteSettingUseCase interface {
|
type siteSettingUseCase interface {
|
||||||
GetSettings() map[string]string
|
GetSettings() map[string]string
|
||||||
UpdateSetting(key, value string) error
|
UpdateSetting(key, value string) error
|
||||||
UpdateBoolSetting(key string, value bool) error
|
UpdateBoolSetting(key string, value bool) error
|
||||||
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
||||||
|
UpdateTheme(themeName string, reload service.ThemeReloader) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditStatusProvider 审核控制器所需的用户信息查询(ISP)
|
// auditStatusProvider 审核控制器所需的用户信息查询(ISP)
|
||||||
@ -36,7 +37,7 @@ type auditStatusProvider interface {
|
|||||||
FindByID(id uint) (*model.User, error)
|
FindByID(id uint) (*model.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:7 个方法)
|
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:9 个方法)
|
||||||
type adminPostUseCase interface {
|
type adminPostUseCase interface {
|
||||||
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
|
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
@ -44,6 +45,8 @@ type adminPostUseCase interface {
|
|||||||
Reject(postID uint, reason string) error
|
Reject(postID uint, reason string) error
|
||||||
Lock(postID uint, reason string) error
|
Lock(postID uint, reason string) error
|
||||||
Unlock(postID uint) error
|
Unlock(postID uint) error
|
||||||
|
Pin(postID uint, pinType string) error
|
||||||
|
Unpin(postID uint) error
|
||||||
Restore(postID uint) error
|
Restore(postID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
"metazone.cc/mce/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -14,6 +16,7 @@ import (
|
|||||||
type SiteSettingController struct {
|
type SiteSettingController struct {
|
||||||
settingService siteSettingUseCase
|
settingService siteSettingUseCase
|
||||||
defaults *service.AuditDefaults
|
defaults *service.AuditDefaults
|
||||||
|
themeReload service.ThemeReloader // 主题重载回调(切换后实时生效)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSiteSettingController 构造函数
|
// NewSiteSettingController 构造函数
|
||||||
@ -21,12 +24,63 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
|
|||||||
return &SiteSettingController{settingService: settingService, defaults: defaults}
|
return &SiteSettingController{settingService: settingService, defaults: defaults}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteSettingsPage 站点设置管理页面
|
// SetThemeReloader 注入主题重载回调(main.go 中设置)
|
||||||
|
func (sc *SiteSettingController) SetThemeReloader(reload service.ThemeReloader) {
|
||||||
|
sc.themeReload = reload
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页
|
||||||
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{
|
c.Redirect(http.StatusMovedPermanently, "/admin/settings/brand")
|
||||||
"Title": "站点设置",
|
}
|
||||||
"ExtraCSS": "/admin/static/css/site-settings.css",
|
|
||||||
"ExtraJS": "/admin/static/js/site-settings.js",
|
// BrandPage 品牌与合规设置页
|
||||||
|
func (sc *SiteSettingController) BrandPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/brand.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 品牌与合规",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "brand",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityPage 安全与维护设置页
|
||||||
|
func (sc *SiteSettingController) SecurityPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/security.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 安全与维护",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "security",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationPage 注册策略设置页
|
||||||
|
func (sc *SiteSettingController) RegistrationPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/registration.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 注册策略",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "registration",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentPage 审核与内容设置页
|
||||||
|
func (sc *SiteSettingController) ContentPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/content.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 审核与内容",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings.js",
|
||||||
|
"ActiveSetting": "content",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemePage 外观主题设置页
|
||||||
|
func (sc *SiteSettingController) ThemePage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/theme.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 外观主题",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings-theme.js",
|
||||||
|
"ActiveSetting": "theme",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,6 +88,10 @@ func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
|||||||
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
||||||
all := sc.settingService.GetSettings()
|
all := sc.settingService.GetSettings()
|
||||||
audit := sc.settingService.GetAuditSettings(sc.defaults)
|
audit := sc.settingService.GetAuditSettings(sc.defaults)
|
||||||
|
log.Printf("[GetSettings] all=%d keys audit=%+v", len(all), audit)
|
||||||
|
if v, ok := all["registration.enabled"]; ok {
|
||||||
|
log.Printf("[GetSettings] registration.enabled=%q", v)
|
||||||
|
}
|
||||||
common.Ok(c, gin.H{
|
common.Ok(c, gin.H{
|
||||||
"all": all,
|
"all": all,
|
||||||
"audit": audit,
|
"audit": audit,
|
||||||
@ -110,3 +168,60 @@ func (sc *SiteSettingController) GetBoolSetting(c *gin.Context) {
|
|||||||
common.Ok(c, gin.H{"value": d})
|
common.Ok(c, gin.H{"value": d})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetThemes 获取所有可用主题列表
|
||||||
|
func (sc *SiteSettingController) GetThemes(c *gin.Context) {
|
||||||
|
themes, err := theme.DiscoverThemes("templates")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if themes == nil {
|
||||||
|
themes = []theme.ThemeInfo{}
|
||||||
|
}
|
||||||
|
// 读取当前主题
|
||||||
|
all := sc.settingService.GetSettings()
|
||||||
|
currentTheme := "MetaLab-2026"
|
||||||
|
if t, ok := all["site.theme"]; ok && t != "" {
|
||||||
|
currentTheme = t
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"themes": themes,
|
||||||
|
"current": currentTheme,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTheme 切换主题
|
||||||
|
func (sc *SiteSettingController) UpdateTheme(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Theme string `json:"theme" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证主题是否存在
|
||||||
|
themes, err := theme.DiscoverThemes("templates")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, t := range themes {
|
||||||
|
if t.Dir == req.Theme {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
common.Error(c, http.StatusBadRequest, "主题不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.settingService.UpdateTheme(req.Theme, sc.themeReload); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "主题切换失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "主题已切换")
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -48,21 +49,20 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
user, err := ac.authService.Login(req, ip)
|
user, err := ac.authService.Login(req, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
|
// 失败计数已在 AllowAccount/AllowIP 中原子递增,无需额外记录
|
||||||
switch err {
|
if errors.Is(err, common.ErrInvalidCred) {
|
||||||
case common.ErrInvalidCred:
|
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
case common.ErrUserLocked:
|
} else if errors.Is(err, common.ErrUserLocked) {
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
case common.ErrMaintenanceMode:
|
} else if errors.Is(err, common.ErrMaintenanceMode) {
|
||||||
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
|
common.Error(c, http.StatusForbidden, "社区正在维护中,仅站长可登录")
|
||||||
case common.ErrNeedsConfirmRestore:
|
} else if errors.Is(err, common.ErrNeedsConfirmRestore) {
|
||||||
// 注销账号登录 → 需要二次确认恢复
|
// 注销账号登录 → 需要二次确认恢复
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"action": "confirm_restore",
|
"action": "confirm_restore",
|
||||||
"message": "你的账号正在注销中,登录将撤销注销并恢复账号",
|
"message": "你的账号正在注销中,登录将撤销注销并恢复账号",
|
||||||
})
|
})
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
|
common.Error(c, http.StatusInternalServerError, "登录失败,请稍后重试")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -78,7 +78,7 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
|
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg, ac.siteSettings)
|
||||||
|
|
||||||
common.OkWithMessage(c, user, "登录成功")
|
common.OkWithMessage(c, user, "登录成功")
|
||||||
}
|
}
|
||||||
@ -93,10 +93,9 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
|
|||||||
|
|
||||||
user, err := ac.authService.ConfirmRestore(req, clientIP(c))
|
user, err := ac.authService.ConfirmRestore(req, clientIP(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
if errors.Is(err, common.ErrInvalidCred) {
|
||||||
case common.ErrInvalidCred:
|
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
|
common.Error(c, http.StatusInternalServerError, "操作失败,请稍后重试")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -108,6 +107,6 @@ func (ac *AuthController) ConfirmRestore(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
|
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg, ac.siteSettings)
|
||||||
common.OkWithMessage(c, user, "注销已撤销,欢迎回来")
|
common.OkWithMessage(c, user, "注销已撤销,欢迎回来")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -45,16 +46,15 @@ func (ac *AuthController) Register(c *gin.Context) {
|
|||||||
user, err := ac.authService.Register(req, clientIP(c))
|
user, err := ac.authService.Register(req, clientIP(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 失败计数已在 AllowIP 中原子递增,无需额外记录
|
// 失败计数已在 AllowIP 中原子递增,无需额外记录
|
||||||
switch err {
|
if errors.Is(err, common.ErrMaintenanceMode) {
|
||||||
case common.ErrMaintenanceMode:
|
|
||||||
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
|
common.Error(c, http.StatusForbidden, "社区正在维护中,暂不支持注册")
|
||||||
case common.ErrEmailExists:
|
} else if errors.Is(err, common.ErrEmailExists) {
|
||||||
common.Error(c, http.StatusConflict, "该邮箱已注册")
|
common.Error(c, http.StatusConflict, "该邮箱已注册")
|
||||||
case common.ErrWeakPassword:
|
} else if errors.Is(err, common.ErrWeakPassword) {
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
case common.ErrRegistrationDisabled:
|
} else if errors.Is(err, common.ErrRegistrationDisabled) {
|
||||||
common.Error(c, http.StatusForbidden, "注册功能已关闭")
|
common.Error(c, http.StatusForbidden, "注册功能已关闭")
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
|
common.Error(c, http.StatusInternalServerError, "注册失败,请稍后重试")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -69,9 +69,9 @@ func (ac *AuthController) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg)
|
common.SetSessionCookie(c, sid, req.RememberMe, ac.cfg, ac.siteSettings)
|
||||||
|
|
||||||
common.OkWithMessage(c, user, "注册成功!欢迎加入 MetaLab")
|
common.OkWithMessage(c, user, "注册成功!欢迎加入 "+ac.siteSettings.SiteInfo().SiteName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout 退出登录:删除服务端 session + 清除 Cookie
|
// Logout 退出登录:删除服务端 session + 清除 Cookie
|
||||||
@ -79,7 +79,7 @@ func (ac *AuthController) Logout(c *gin.Context) {
|
|||||||
if sid, err := c.Cookie(common.SessionCookieName); err == nil && sid != "" {
|
if sid, err := c.Cookie(common.SessionCookieName); err == nil && sid != "" {
|
||||||
_ = ac.sessionManager.Destroy(sid)
|
_ = ac.sessionManager.Destroy(sid)
|
||||||
}
|
}
|
||||||
common.ClearSessionCookie(c, ac.cfg)
|
common.ClearSessionCookie(c, ac.cfg, ac.siteSettings)
|
||||||
common.OkMessage(c, "已退出登录")
|
common.OkMessage(c, "已退出登录")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +93,7 @@ func (ac *AuthController) RefreshToken(c *gin.Context) {
|
|||||||
|
|
||||||
s, err := ac.sessionManager.Validate(sid)
|
s, err := ac.sessionManager.Validate(sid)
|
||||||
if err != nil || s == nil {
|
if err != nil || s == nil {
|
||||||
common.ClearSessionCookie(c, ac.cfg)
|
common.ClearSessionCookie(c, ac.cfg, ac.siteSettings)
|
||||||
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
common.Error(c, http.StatusUnauthorized, "登录凭证已失效,请重新登录")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,12 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/mce/internal/session"
|
||||||
"metazone.cc/metalab/internal/theme"
|
"metazone.cc/mce/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -36,20 +37,32 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
|||||||
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
|
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
|
||||||
var guidelines template.HTML
|
var guidelines template.HTML
|
||||||
if guidelinesHTML != "" {
|
if guidelinesHTML != "" {
|
||||||
guidelines = template.HTML(guidelinesHTML)
|
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
|
||||||
} else {
|
} else {
|
||||||
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
content, err := theme.LoadContent("templates/" + ac.siteSettings.ThemeName() + "/guidelines.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载准则失败")
|
c.String(http.StatusInternalServerError, "加载准则失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guidelines = content
|
guidelines = content
|
||||||
}
|
}
|
||||||
|
// 替换准则中的硬编码站点名和邮箱
|
||||||
|
siteInfo := ac.siteSettings.SiteInfo()
|
||||||
|
guidelines = template.HTML( //nolint:gosec // 准则内容由管理员配置,站点名/邮箱来自 DB
|
||||||
|
strings.ReplaceAll(
|
||||||
|
strings.ReplaceAll(string(guidelines), "MetaLab", siteInfo.SiteName),
|
||||||
|
"metazone@foxmail.com", siteInfo.ContactEmail,
|
||||||
|
),
|
||||||
|
)
|
||||||
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "注册",
|
"Title": "注册",
|
||||||
"ExtraCSS": "/static/css/auth.css",
|
"ExtraCSS": "/static/css/auth.css",
|
||||||
"Guidelines": guidelines,
|
"Guidelines": guidelines,
|
||||||
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
|
"RegistrationEnabled": ac.authService.IsRegistrationEnabled(),
|
||||||
|
"PasswordStrength": ac.siteSettings.PasswordStrength(),
|
||||||
|
"ShowGuidelinesModal": ac.siteSettings.ShowGuidelinesModal(),
|
||||||
|
"ForceGuidelines": ac.siteSettings.ForceGuidelines(),
|
||||||
|
"GuidelinesTimer": ac.siteSettings.GuidelinesTimer(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -41,7 +41,7 @@ func (ctrl *CommentController) Delete(c *gin.Context) {
|
|||||||
|
|
||||||
// SearchUsers GET /api/users/search?q=keyword
|
// SearchUsers GET /api/users/search?q=keyword
|
||||||
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
||||||
_, _, ok := common.GetGinUser(c)
|
uid, _, ok := common.GetGinUser(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
return
|
return
|
||||||
@ -53,7 +53,7 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := ctrl.commentService.SearchUsers(q)
|
users, err := ctrl.commentService.SearchUsers(q, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -28,12 +28,11 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LV4+ 检查(Exp ≥ 1500)
|
// LV4+ 检查(Exp ≥ 1500)
|
||||||
expVal, exists := c.Get("exp")
|
exp, ok := common.GetGinExp(c)
|
||||||
if !exists {
|
if !ok {
|
||||||
common.Error(c, http.StatusForbidden, "无法获取经验值")
|
common.Error(c, http.StatusForbidden, "无法获取经验值")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exp := expVal.(int)
|
|
||||||
if exp < 1500 {
|
if exp < 1500 {
|
||||||
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
|
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
|
||||||
return
|
return
|
||||||
@ -44,7 +43,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
|||||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
// 检查扩展名
|
// 检查扩展名
|
||||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
@ -67,7 +66,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
storageDir := "storage/uploads/posts"
|
storageDir := "storage/uploads/posts"
|
||||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
if err := os.MkdirAll(storageDir, 0o755); err != nil { //nolint:gosec // 上传目录标准权限
|
||||||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -88,7 +87,7 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
|||||||
if webpData != nil && len(webpData) < len(data) {
|
if webpData != nil && len(webpData) < len(data) {
|
||||||
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
|
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
|
||||||
savePath = filepath.Join(storageDir, filename)
|
savePath = filepath.Join(storageDir, filename)
|
||||||
os.WriteFile(savePath, webpData, 0644)
|
_ = os.WriteFile(savePath, webpData, 0o644) //nolint:gosec // 上传文件标准权限
|
||||||
url = "/uploads/posts/" + filename
|
url = "/uploads/posts/" + filename
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -101,16 +100,16 @@ func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
|||||||
if decErr == nil {
|
if decErr == nil {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if ext == ".png" {
|
if ext == ".png" {
|
||||||
png.Encode(&buf, decImg)
|
_ = png.Encode(&buf, decImg)
|
||||||
} else {
|
} else {
|
||||||
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
_ = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||||
}
|
}
|
||||||
dataOut = buf.Bytes()
|
dataOut = buf.Bytes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
|
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
|
||||||
savePath = filepath.Join(storageDir, filename)
|
savePath = filepath.Join(storageDir, filename)
|
||||||
os.WriteFile(savePath, dataOut, 0644)
|
_ = os.WriteFile(savePath, dataOut, 0o644) //nolint:gosec // 上传文件标准权限
|
||||||
url = "/uploads/posts/" + filename
|
url = "/uploads/posts/" + filename
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -39,18 +40,17 @@ func (ec *EnergyController) Energize(c *gin.Context) {
|
|||||||
|
|
||||||
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
if errors.Is(err, common.ErrPostNotFound) {
|
||||||
case common.ErrPostNotFound:
|
|
||||||
common.Error(c, http.StatusNotFound, "文章不存在")
|
common.Error(c, http.StatusNotFound, "文章不存在")
|
||||||
case common.ErrCannotEnergizeSelf:
|
} else if errors.Is(err, common.ErrCannotEnergizeSelf) {
|
||||||
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
|
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
|
||||||
case common.ErrInsufficientEnergy:
|
} else if errors.Is(err, common.ErrInsufficientEnergy) {
|
||||||
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
|
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
|
||||||
case common.ErrEnergizeLimitReached:
|
} else if errors.Is(err, common.ErrEnergizeLimitReached) {
|
||||||
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
|
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
|
||||||
case common.ErrInvalidEnergyAmount:
|
} else if errors.Is(err, common.ErrInvalidEnergyAmount) {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
|
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "赋能失败")
|
common.Error(c, http.StatusInternalServerError, "赋能失败")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -136,13 +136,13 @@ func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
|
|||||||
|
|
||||||
// EnergyLogView 前端展示用的域能流水视图
|
// EnergyLogView 前端展示用的域能流水视图
|
||||||
type EnergyLogView struct {
|
type EnergyLogView struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Amount int `json:"amount"`
|
Amount int `json:"amount"`
|
||||||
AmountDisplay float64 `json:"amount_display"`
|
AmountDisplay float64 `json:"amount_display"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TypeName string `json:"type_name"`
|
TypeName string `json:"type_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {
|
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -24,7 +25,7 @@ func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
|
|||||||
|
|
||||||
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
|
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == common.ErrPermissionDenied {
|
if errors.Is(err, common.ErrPermissionDenied) {
|
||||||
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
|
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -29,13 +29,13 @@ func (fc *FollowController) Toggle(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
|
isFollowing, err := fc.followSvc.Toggle(uid, targetUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "不能关注自己" {
|
if err.Error() == "不能关注自己" {
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
@ -46,7 +46,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取最新状态
|
// 获取最新状态
|
||||||
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
|
status, _ := fc.followSvc.GetStatus(uid, targetUID)
|
||||||
|
|
||||||
common.Ok(c, gin.H{
|
common.Ok(c, gin.H{
|
||||||
"is_following": isFollowing,
|
"is_following": isFollowing,
|
||||||
@ -56,7 +56,7 @@ func (fc *FollowController) Toggle(c *gin.Context) {
|
|||||||
|
|
||||||
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
|
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
|
||||||
func (fc *FollowController) GetStatus(c *gin.Context) {
|
func (fc *FollowController) GetStatus(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -64,7 +64,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
|
|||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false)
|
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false)
|
||||||
|
|
||||||
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
|
status, err := fc.followSvc.GetStatus(uid, targetUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
return
|
return
|
||||||
@ -75,7 +75,7 @@ func (fc *FollowController) GetStatus(c *gin.Context) {
|
|||||||
|
|
||||||
// Followers 粉丝列表 GET /api/users/:uid/followers
|
// Followers 粉丝列表 GET /api/users/:uid/followers
|
||||||
func (fc *FollowController) Followers(c *gin.Context) {
|
func (fc *FollowController) Followers(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -86,7 +86,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
|
|||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c) // 允许未登录
|
uid, _, _ := common.GetGinUser(c) // 允许未登录
|
||||||
|
|
||||||
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
|
result, err := fc.followSvc.ListFollowers(targetUID, uid, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
return
|
return
|
||||||
@ -97,7 +97,7 @@ func (fc *FollowController) Followers(c *gin.Context) {
|
|||||||
|
|
||||||
// Following 关注列表 GET /api/users/:uid/following
|
// Following 关注列表 GET /api/users/:uid/following
|
||||||
func (fc *FollowController) Following(c *gin.Context) {
|
func (fc *FollowController) Following(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -108,7 +108,7 @@ func (fc *FollowController) Following(c *gin.Context) {
|
|||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c)
|
uid, _, _ := common.GetGinUser(c)
|
||||||
|
|
||||||
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
|
result, err := fc.followSvc.ListFollowing(targetUID, uid, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
return
|
return
|
||||||
@ -129,7 +129,7 @@ func NewFollowPageController(followSvc followUseCase) *FollowPageController {
|
|||||||
|
|
||||||
// FollowersPage 粉丝列表页面
|
// FollowersPage 粉丝列表页面
|
||||||
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -138,7 +138,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c)
|
uid, _, _ := common.GetGinUser(c)
|
||||||
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
|
result, err := fpc.followSvc.ListFollowers(targetUID, uid, page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
}
|
}
|
||||||
@ -148,7 +148,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
|||||||
"ExtraCSS": "/static/css/follow.css",
|
"ExtraCSS": "/static/css/follow.css",
|
||||||
"Result": result,
|
"Result": result,
|
||||||
"TargetUID": targetUID,
|
"TargetUID": targetUID,
|
||||||
"Page": result.Page,
|
"Page": result.Page,
|
||||||
"TotalPages": result.TotalPages,
|
"TotalPages": result.TotalPages,
|
||||||
"HasPrev": result.Page > 1,
|
"HasPrev": result.Page > 1,
|
||||||
"HasNext": result.Page < result.TotalPages,
|
"HasNext": result.Page < result.TotalPages,
|
||||||
@ -159,7 +159,7 @@ func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
|||||||
|
|
||||||
// FollowingPage 关注列表页面
|
// FollowingPage 关注列表页面
|
||||||
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
||||||
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
targetUID, err := common.ParseUIDParam(c, "uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
return
|
return
|
||||||
@ -168,7 +168,7 @@ func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|
||||||
uid, _, _ := common.GetGinUser(c)
|
uid, _, _ := common.GetGinUser(c)
|
||||||
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
|
result, err := fpc.followSvc.ListFollowing(targetUID, uid, page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
|
// Package controller 定义 HTTP 请求处理层,通过 ISP 接口隔离依赖 Service。
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/middleware"
|
"metazone.cc/mce/internal/middleware"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/mce/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:5 个方法)
|
// authUseCase AuthController 对 AuthService 的最小依赖(ISP:5 个方法)
|
||||||
@ -27,13 +28,10 @@ type rateLimiter interface {
|
|||||||
ClearIP(ipKey string)
|
ClearIP(ipKey string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法)
|
// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||||
type postUseCase interface {
|
type postUseCase interface {
|
||||||
Create(userID uint, title, body string) (*model.Post, error)
|
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
List(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
List(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
Update(postID uint, title, body string) error
|
|
||||||
Delete(postID uint) error
|
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
RecordRead(userID, postID uint)
|
RecordRead(userID, postID uint)
|
||||||
RecordGuestRead(visitorID string, postID uint)
|
RecordGuestRead(visitorID string, postID uint)
|
||||||
@ -42,9 +40,9 @@ type postUseCase interface {
|
|||||||
|
|
||||||
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
||||||
type studioUseCase interface {
|
type studioUseCase interface {
|
||||||
Create(userID uint, title, body string) (*model.Post, error)
|
Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) (*model.Post, error)
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
Update(postID uint, title, body string) error
|
Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID, scheduledAt string) error
|
||||||
Delete(postID uint) error
|
Delete(postID uint) error
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
@ -100,14 +98,6 @@ type energyRenameHandler interface {
|
|||||||
DeductOnRename(userID uint) error
|
DeductOnRename(userID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:4 个方法)
|
|
||||||
type energyAdminUseCase interface {
|
|
||||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
|
||||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
|
||||||
GetFundBalance() (int, error)
|
|
||||||
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
||||||
type commentUseCase interface {
|
type commentUseCase interface {
|
||||||
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
||||||
@ -115,7 +105,7 @@ type commentUseCase interface {
|
|||||||
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
||||||
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
||||||
ListReplies(rootID uint) ([]model.Comment, error)
|
ListReplies(rootID uint) ([]model.Comment, error)
|
||||||
SearchUsers(keyword string) ([]model.UserSearchResult, error)
|
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法)
|
// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"image"
|
"image"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
"github.com/chai2010/webp"
|
"github.com/chai2010/webp"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/chai2010/webp"
|
"github.com/chai2010/webp"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -34,7 +34,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
|||||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
// 检查扩展名
|
// 检查扩展名
|
||||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
@ -59,7 +59,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
|||||||
|
|
||||||
// 存储路径
|
// 存储路径
|
||||||
storageDir := "storage/uploads/posts"
|
storageDir := "storage/uploads/posts"
|
||||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
if err := os.MkdirAll(storageDir, 0o755); err != nil { //nolint:gosec // 上传目录标准权限
|
||||||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -83,7 +83,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
|||||||
if webpData != nil && len(webpData) < len(data) {
|
if webpData != nil && len(webpData) < len(data) {
|
||||||
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
|
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
|
||||||
savePath = filepath.Join(storageDir, filename)
|
savePath = filepath.Join(storageDir, filename)
|
||||||
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
|
if err := os.WriteFile(savePath, webpData, 0o644); err == nil { //nolint:gosec // 上传文件标准权限
|
||||||
url = "/uploads/posts/" + filename
|
url = "/uploads/posts/" + filename
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,7 +127,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
|
|||||||
|
|
||||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||||
savePath = filepath.Join(storageDir, filename)
|
savePath = filepath.Join(storageDir, filename)
|
||||||
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
|
if err := os.WriteFile(savePath, dataOut, 0o644); err != nil { //nolint:gosec // 上传文件标准权限
|
||||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,8 +3,8 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -49,10 +50,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||||
if hasCompleted {
|
if hasCompleted {
|
||||||
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||||
switch err {
|
if errors.Is(err, common.ErrInsufficientEnergy) {
|
||||||
case common.ErrInsufficientEnergy:
|
|
||||||
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -81,10 +81,9 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||||
if hasCompleted {
|
if hasCompleted {
|
||||||
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||||
switch err {
|
if errors.Is(err, common.ErrInsufficientEnergy) {
|
||||||
case common.ErrInsufficientEnergy:
|
|
||||||
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -122,7 +121,7 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
|||||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
// 裁切参数(可选,来自前端裁切弹窗)
|
// 裁切参数(可选,来自前端裁切弹窗)
|
||||||
cropX, _ := strconv.Atoi(c.PostForm("crop_x"))
|
cropX, _ := strconv.Atoi(c.PostForm("crop_x"))
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/mce/internal/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -68,6 +68,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
data := gin.H{
|
data := gin.H{
|
||||||
"Title": "个人设置",
|
"Title": "个人设置",
|
||||||
"ExtraCSS": "/static/css/settings.css",
|
"ExtraCSS": "/static/css/settings.css",
|
||||||
|
"ExtraJS": "/static/js/settings-" + tab + ".js",
|
||||||
"ActiveTab": tab,
|
"ActiveTab": tab,
|
||||||
"User": user,
|
"User": user,
|
||||||
"RoleName": model.RoleDisplayNames[user.Role],
|
"RoleName": model.RoleDisplayNames[user.Role],
|
||||||
@ -113,5 +114,22 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
data["CurrentSID"] = currentSID
|
data["CurrentSID"] = currentSID
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
|
// 按 tab 渲染独立模板
|
||||||
|
tplName := "settings/index.html"
|
||||||
|
switch tab {
|
||||||
|
case "profile":
|
||||||
|
tplName = "settings/profile.html"
|
||||||
|
case "account":
|
||||||
|
tplName = "settings/account.html"
|
||||||
|
case "sessions":
|
||||||
|
tplName = "settings/sessions.html"
|
||||||
|
case "energy":
|
||||||
|
tplName = "settings/energy.html"
|
||||||
|
case "favorites":
|
||||||
|
tplName = "settings/favorites.html"
|
||||||
|
case "notify":
|
||||||
|
tplName = "settings/notify.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, tplName, common.BuildPageData(c, data))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,40 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleSettingsError 统一处理 settings 接口的 service 层错误
|
// handleSettingsError 统一处理 settings 接口的 service 层错误
|
||||||
func handleSettingsError(c *gin.Context, err error) {
|
func handleSettingsError(c *gin.Context, err error) {
|
||||||
switch err {
|
if errors.Is(err, common.ErrUsernameTaken) {
|
||||||
case common.ErrUsernameTaken:
|
|
||||||
common.Error(c, http.StatusConflict, err.Error())
|
common.Error(c, http.StatusConflict, err.Error())
|
||||||
case common.ErrUsernameInvalid:
|
} else if errors.Is(err, common.ErrUsernameInvalid) {
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
case common.ErrBioTooLong:
|
} else if errors.Is(err, common.ErrBioTooLong) {
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
case common.ErrIncorrectPassword:
|
} else if errors.Is(err, common.ErrIncorrectPassword) {
|
||||||
common.Error(c, http.StatusForbidden, err.Error())
|
common.Error(c, http.StatusForbidden, err.Error())
|
||||||
case common.ErrOwnerCannotDelete:
|
} else if errors.Is(err, common.ErrOwnerCannotDelete) {
|
||||||
common.Error(c, http.StatusForbidden, err.Error())
|
common.Error(c, http.StatusForbidden, err.Error())
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAuditSubmitError 统一处理审核提交的错误
|
// handleAuditSubmitError 统一处理审核提交的错误
|
||||||
func handleAuditSubmitError(c *gin.Context, err error) {
|
func handleAuditSubmitError(c *gin.Context, err error) {
|
||||||
switch err {
|
if errors.Is(err, common.ErrAuditDisabled) {
|
||||||
case common.ErrAuditDisabled:
|
|
||||||
common.Error(c, http.StatusForbidden, "审核功能未开启")
|
common.Error(c, http.StatusForbidden, "审核功能未开启")
|
||||||
case common.ErrAuditTypeOff:
|
} else if errors.Is(err, common.ErrAuditTypeOff) {
|
||||||
common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员")
|
common.Error(c, http.StatusForbidden, "该类型审核未开启,请联系管理员")
|
||||||
case common.ErrUsernameTaken:
|
} else if errors.Is(err, common.ErrUsernameTaken) {
|
||||||
common.Error(c, http.StatusConflict, err.Error())
|
common.Error(c, http.StatusConflict, err.Error())
|
||||||
default:
|
} else {
|
||||||
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,16 +4,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
|
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
|
||||||
type SpaceController struct {
|
type SpaceController struct {
|
||||||
spaceService spaceUseCase
|
spaceService spaceUseCase
|
||||||
followSvc followUseCase
|
followSvc followUseCase
|
||||||
favoriteSvc favoriteUseCaseForSpace
|
favoriteSvc favoriteUseCaseForSpace
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpaceController 构造函数
|
// NewSpaceController 构造函数
|
||||||
@ -73,25 +73,29 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
|||||||
|
|
||||||
// 分页参数
|
// 分页参数
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
if page < 1 { page = 1 }
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18"))
|
||||||
if pageSize < 1 || pageSize > 50 { pageSize = 18 }
|
if pageSize < 1 || pageSize > 50 {
|
||||||
|
pageSize = 18
|
||||||
|
}
|
||||||
|
|
||||||
// 构建基础数据(含用户统计)
|
// 构建基础数据(含用户统计)
|
||||||
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
|
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
|
||||||
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
|
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"Title": spaceUser.Username + " 的空间",
|
"Title": spaceUser.Username + " 的空间",
|
||||||
"SpaceUser": spaceUser,
|
"SpaceUser": spaceUser,
|
||||||
"IsOwnSpace": isOwnSpace,
|
"IsOwnSpace": isOwnSpace,
|
||||||
"ActiveTab": tab,
|
"ActiveTab": tab,
|
||||||
"CurrentUID": currentUID,
|
"CurrentUID": currentUID,
|
||||||
"ExtraCSS": "/static/css/space.css",
|
"ExtraCSS": "/static/css/space.css",
|
||||||
"PostCount": postCount,
|
"PostCount": postCount,
|
||||||
"TotalLikes": totalLikes,
|
"TotalLikes": totalLikes,
|
||||||
"TotalFavorites": totalFavorites,
|
"TotalFavorites": totalFavorites,
|
||||||
"TotalReads": totalReads,
|
"TotalReads": totalReads,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 根据 Tab 加载对应数据 ----
|
// ---- 根据 Tab 加载对应数据 ----
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/mce/internal/model"
|
||||||
|
"metazone.cc/mce/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// trendsUseCase StudioController 对趋势服务的最小依赖(ISP)
|
// trendsUseCase StudioController 对趋势服务的最小依赖(ISP)
|
||||||
@ -9,11 +10,24 @@ type trendsUseCase interface {
|
|||||||
GetTrends(userID uint, since string) (*service.TrendResult, error)
|
GetTrends(userID uint, since string) (*service.TrendResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagParser ISP:标签解析
|
||||||
|
type tagParser interface {
|
||||||
|
ParseAndSave(tagStr string) ([]uint, error)
|
||||||
|
SetPostTags(postID uint, tagStr string) error
|
||||||
|
}
|
||||||
|
|
||||||
// StudioController 创作中心控制器(SSR 页面 + API)
|
// StudioController 创作中心控制器(SSR 页面 + API)
|
||||||
type StudioController struct {
|
type StudioController struct {
|
||||||
postService studioUseCase
|
postService studioUseCase
|
||||||
energySvc energyDeducter
|
energySvc energyDeducter
|
||||||
trendsSvc trendsUseCase
|
trendsSvc trendsUseCase
|
||||||
|
tagSvc tagParser
|
||||||
|
categorySvc categoryLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryLoader ISP:分类叶子节点查询
|
||||||
|
type categoryLoader interface {
|
||||||
|
GetLeaves() ([]model.Category, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStudioController 构造函数
|
// NewStudioController 构造函数
|
||||||
@ -32,3 +46,15 @@ func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioContro
|
|||||||
ctrl.trendsSvc = svc
|
ctrl.trendsSvc = svc
|
||||||
return ctrl
|
return ctrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTagService 链式注入标签服务
|
||||||
|
func (ctrl *StudioController) WithTagService(svc tagParser) *StudioController {
|
||||||
|
ctrl.tagSvc = svc
|
||||||
|
return ctrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCategoryService 链式注入分类服务
|
||||||
|
func (ctrl *StudioController) WithCategoryService(svc categoryLoader) *StudioController {
|
||||||
|
ctrl.categorySvc = svc
|
||||||
|
return ctrl
|
||||||
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -30,12 +31,21 @@ func (ctrl *StudioController) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrScheduledTooSoon) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
common.Error(c, http.StatusInternalServerError, "发布失败")
|
common.Error(c, http.StatusInternalServerError, "发布失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关联标签
|
||||||
|
if ctrl.tagSvc != nil && req.Tags != "" {
|
||||||
|
_ = ctrl.tagSvc.SetPostTags(post.ID, req.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
common.OkWithMessage(c, post, "发布成功")
|
common.OkWithMessage(c, post, "发布成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,10 +79,15 @@ func (ctrl *StudioController) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
|
if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID, req.ScheduledAt); err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
if ctrl.tagSvc != nil {
|
||||||
|
_ = ctrl.tagSvc.SetPostTags(uint(id), req.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
common.OkMessage(c, "编辑成功")
|
common.OkMessage(c, "编辑成功")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -17,6 +17,16 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载分类列表(叶子节点)
|
||||||
|
var categories []interface{}
|
||||||
|
if ctrl.categorySvc != nil {
|
||||||
|
if leaves, err := ctrl.categorySvc.GetLeaves(); err == nil {
|
||||||
|
for _, cat := range leaves {
|
||||||
|
categories = append(categories, cat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 支持编辑模式:传入 post_id 参数
|
// 支持编辑模式:传入 post_id 参数
|
||||||
postIDStr := c.Query("id")
|
postIDStr := c.Query("id")
|
||||||
if postIDStr != "" {
|
if postIDStr != "" {
|
||||||
@ -32,10 +42,11 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "编辑文章 - 创作中心",
|
"Title": "编辑文章 - 创作中心",
|
||||||
"Post": post,
|
"Post": post,
|
||||||
"ActiveTab": "write",
|
"ActiveTab": "write",
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
"Categories": categories,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -43,9 +54,10 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "写文章 - 创作中心",
|
"Title": "写文章 - 创作中心",
|
||||||
"ActiveTab": "write",
|
"ActiveTab": "write",
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
"Categories": categories,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
internal/controller/tag_controller.go
Normal file
54
internal/controller/tag_controller.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/mce/internal/common"
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TagController 标签控制器(标签落地页)
|
||||||
|
type TagController struct {
|
||||||
|
svc tagUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagUseCase interface {
|
||||||
|
GetPostsByTag(slug string, page, pageSize int) ([]model.Post, int64, *model.Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTagController 构造函数
|
||||||
|
func NewTagController(svc tagUseCase) *TagController {
|
||||||
|
return &TagController{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagPage 标签落地页
|
||||||
|
func (tc *TagController) TagPage(c *gin.Context) {
|
||||||
|
slug := c.Param("slug")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize := 20
|
||||||
|
|
||||||
|
posts, total, tag, err := tc.svc.GetPostsByTag(slug, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
"ErrorMsg": "该标签不存在",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := common.PageCount(total, pageSize)
|
||||||
|
c.HTML(http.StatusOK, "tags/detail.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "标签:" + tag.Name,
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
"Posts": posts,
|
||||||
|
"Total": total,
|
||||||
|
"Page": page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"PrevPage": max(1, page-1),
|
||||||
|
"NextPage": min(totalPages, page+1),
|
||||||
|
"Tag": tag,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/mce/internal/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -22,6 +22,7 @@ type AuthMiddleware struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
sessionManager *session.Manager
|
sessionManager *session.Manager
|
||||||
levelSvc autoCheckIner
|
levelSvc autoCheckIner
|
||||||
|
siteSettings *config.SiteSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware 构造函数
|
// NewAuthMiddleware 构造函数
|
||||||
@ -29,6 +30,12 @@ func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware
|
|||||||
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSiteSettings 链式注入站点设置(用于 cookie_secure 运行时覆盖)
|
||||||
|
func (am *AuthMiddleware) WithSiteSettings(ss *config.SiteSettings) *AuthMiddleware {
|
||||||
|
am.siteSettings = ss
|
||||||
|
return am
|
||||||
|
}
|
||||||
|
|
||||||
// WithLevelService 链式注入等级服务(用于自动签到)
|
// WithLevelService 链式注入等级服务(用于自动签到)
|
||||||
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
|
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
|
||||||
am.levelSvc = svc
|
am.levelSvc = svc
|
||||||
@ -60,7 +67,7 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
|||||||
|
|
||||||
// abortUnauthorized 统一处理未认证:API 返回 JSON,页面请求跳转登录
|
// abortUnauthorized 统一处理未认证:API 返回 JSON,页面请求跳转登录
|
||||||
func (am *AuthMiddleware) abortUnauthorized(c *gin.Context) {
|
func (am *AuthMiddleware) abortUnauthorized(c *gin.Context) {
|
||||||
common.ClearSessionCookie(c, am.cfg)
|
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
"success": false, "message": "登录已过期,请重新登录",
|
"success": false, "message": "登录已过期,请重新登录",
|
||||||
@ -81,7 +88,7 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
|||||||
|
|
||||||
s, err := am.sessionManager.Validate(sid)
|
s, err := am.sessionManager.Validate(sid)
|
||||||
if err != nil || s == nil {
|
if err != nil || s == nil {
|
||||||
common.ClearSessionCookie(c, am.cfg)
|
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -14,7 +14,7 @@ func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
sid, err := c.Cookie(common.SessionCookieName)
|
sid, err := c.Cookie(common.SessionCookieName)
|
||||||
if err != nil || sid == "" {
|
if err != nil || sid == "" {
|
||||||
common.ClearSessionCookie(c, am.cfg)
|
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
|
||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@ -23,7 +23,7 @@ func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc {
|
|||||||
s, err := am.sessionManager.Validate(sid)
|
s, err := am.sessionManager.Validate(sid)
|
||||||
if err != nil || s == nil {
|
if err != nil || s == nil {
|
||||||
log.Printf("[AdminAuth] session invalid path=%s → 302", c.Request.URL.Path)
|
log.Printf("[AdminAuth] session invalid path=%s → 302", c.Request.URL.Path)
|
||||||
common.ClearSessionCookie(c, am.cfg)
|
common.ClearSessionCookie(c, am.cfg, am.siteSettings)
|
||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package middleware 提供 HTTP 中间件:认证、授权、CSRF、安全头、限流等。
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -5,7 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -40,5 +40,3 @@ func generateCSRFToken() (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(b), nil
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
84
internal/middleware/db_health.go
Normal file
84
internal/middleware/db_health.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dbHealthy 全局数据库健康状态(原子读写,默认为健康)
|
||||||
|
var dbHealthy atomic.Bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dbHealthy.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDBHealthCheck 启动定时 DB 健康检测(在 main goroutine 中调用)
|
||||||
|
// 每 5 秒 ping 一次,失败则标记不健康,恢复则重新标记健康
|
||||||
|
func StartDBHealthCheck(db *gorm.DB) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
dbHealthy.Store(false)
|
||||||
|
log.Printf("[DBHealth] 获取底层连接失败: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
if dbHealthy.Swap(false) {
|
||||||
|
log.Printf("[DBHealth] 数据库不可达,整站进入降级模式: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !dbHealthy.Swap(true) {
|
||||||
|
log.Println("[DBHealth] 数据库已恢复,退出降级模式")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBHealthGuard 数据库健康检查中间件
|
||||||
|
// 检测到 DB 不可达时,返回固定维护页面,避免暴露内部堆栈信息
|
||||||
|
func DBHealthGuard() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if dbHealthy.Load() {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
// 静态资源和 API 直接返回错误,不渲染页面
|
||||||
|
if isStaticPath(path) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "服务暂时不可用,请稍后重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 页面请求返回友好 HTML
|
||||||
|
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = c.Writer.WriteString(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="UTF-8"><title>服务暂时不可用</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||||
|
div{text-align:center;padding:2rem} h1{color:#333} p{color:#666}</style></head>
|
||||||
|
<body><div><h1>服务暂时不可用</h1><p>数据库连接异常,请稍后刷新页面重试</p></div></body></html>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStaticPath 判断是否为静态资源或 API 路径
|
||||||
|
func isStaticPath(path string) bool {
|
||||||
|
for _, prefix := range []string{"/static/", "/shared/", "/admin/static/", "/api/"} {
|
||||||
|
if len(path) >= len(prefix) && path[:len(prefix)] == prefix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -4,10 +4,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/mce/internal/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,8 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"metazone.cc/metalab/internal/config"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -28,12 +27,13 @@ func SecurityHeaders() gin.HandlerFunc {
|
|||||||
c.Set("csp_nonce", nonce)
|
c.Set("csp_nonce", nonce)
|
||||||
|
|
||||||
// Content-Security-Policy
|
// Content-Security-Policy
|
||||||
// script-src/style-src: 本站 + nonce(替代 unsafe-inline)
|
// script-src: 本站 + nonce(替代 unsafe-inline),保留 unsafe-eval 供 Vditor 使用
|
||||||
|
// style-src: 本站(所有内联样式已使用 nonce)
|
||||||
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
||||||
c.Header("Content-Security-Policy",
|
c.Header("Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
fmt.Sprintf("script-src 'self' 'unsafe-eval' 'nonce-%s'; ", nonce)+
|
||||||
"style-src 'self' 'unsafe-inline'; "+
|
"style-src 'self' 'nonce-"+nonce+"'; "+
|
||||||
"img-src 'self' data: blob:; "+
|
"img-src 'self' data: blob:; "+
|
||||||
"connect-src 'self'")
|
"connect-src 'self'")
|
||||||
|
|
||||||
@ -46,10 +46,8 @@ func SecurityHeaders() gin.HandlerFunc {
|
|||||||
// 引用策略
|
// 引用策略
|
||||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
// HSTS:仅在 release 模式启用,避免开发环境 localhost 证书问题
|
// HSTS:始终启用(max-age=1年,含子域名,允许 preload 列表收录)
|
||||||
if config.App != nil && config.App.Server.Mode == "release" {
|
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -11,9 +11,21 @@ import (
|
|||||||
func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc {
|
func InjectSiteInfo(siteSettings *config.SiteSettings) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
info := siteSettings.SiteInfo()
|
info := siteSettings.SiteInfo()
|
||||||
|
c.Set("site_name", info.SiteName)
|
||||||
|
c.Set("site_tagline", info.SiteTagline)
|
||||||
|
c.Set("site_contact_email", info.ContactEmail)
|
||||||
c.Set("site_framework", info.Framework)
|
c.Set("site_framework", info.Framework)
|
||||||
c.Set("site_copyright_start", info.CopyrightStart)
|
c.Set("site_copyright_start", info.CopyrightStart)
|
||||||
|
c.Set("site_operator_name", info.OperatorName)
|
||||||
|
c.Set("site_icp_number", info.ICPNumber)
|
||||||
|
c.Set("site_icp_license", info.ICPLicense)
|
||||||
|
c.Set("site_police_number", info.PoliceNumber)
|
||||||
|
c.Set("site_wenwangwen", info.WenWangWen)
|
||||||
|
c.Set("site_algorithm_filing", info.AlgorithmFiling)
|
||||||
|
c.Set("site_privacy_url", info.PrivacyURL)
|
||||||
|
c.Set("site_terms_url", info.TermsURL)
|
||||||
c.Set("is_maintenance", siteSettings.IsMaintenanceEnabled())
|
c.Set("is_maintenance", siteSettings.IsMaintenanceEnabled())
|
||||||
|
c.Set("registration_guidelines_timer", siteSettings.GuidelinesTimer())
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
internal/model/announcement.go
Normal file
16
internal/model/announcement.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Announcement 公告
|
||||||
|
type Announcement struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
Type string `gorm:"type:varchar(20);default:info" json:"type"` // info / warning / success / error
|
||||||
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
|
StartAt *time.Time `json:"start_at,omitempty"`
|
||||||
|
EndAt *time.Time `json:"end_at,omitempty"`
|
||||||
|
Sort int `gorm:"default:0" json:"sort"`
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Package model 定义数据模型、常量、角色体系和审核相关类型。
|
||||||
package model
|
package model
|
||||||
|
|
||||||
// 审核类型常量
|
// 审核类型常量
|
||||||
|
|||||||
15
internal/model/category.go
Normal file
15
internal/model/category.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// Category 文章分类(两级树形结构)
|
||||||
|
type Category struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
|
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
|
||||||
|
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
|
||||||
|
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||||
|
Sort int `gorm:"default:0" json:"sort"`
|
||||||
|
|
||||||
|
// 非 DB 字段(树形填充)
|
||||||
|
Children []Category `gorm:"-" json:"children,omitempty"`
|
||||||
|
}
|
||||||
@ -4,37 +4,42 @@ import "time"
|
|||||||
|
|
||||||
// Comment 评论模型
|
// Comment 评论模型
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
|
||||||
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
||||||
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
|
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||||
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL)
|
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
|
||||||
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL)
|
||||||
LikesCount int `gorm:"default:0" json:"likes_count"`
|
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
||||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
LikesCount int `gorm:"default:0" json:"likes_count"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
||||||
CreatedAt time.Time `json:"created_at"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
|
|
||||||
// 非数据库字段(联表查询填充)
|
// 非数据库字段(联表查询填充)
|
||||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||||
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
|
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
|
||||||
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
|
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
|
||||||
// ReplyToName 被回复者当前显示名(JOIN users 获取,动态解析,改名后自动更新)
|
// ReplyToName 被回复者当前显示名(JOIN users 获取,动态解析,改名后自动更新)
|
||||||
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
|
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
|
||||||
// RepliesCount 回复数(非DB字段,查询填充)
|
// RepliesCount 回复数(非DB字段,查询填充)
|
||||||
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
||||||
// Mentions 有效@提及映射 username -> uid(非DB字段,批量查询填充)
|
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}(非DB字段,批量查询填充)
|
||||||
// 前端据此判断 @username 是否为有效提及并生成链接
|
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
|
||||||
Mentions map[string]uint `gorm:"-" json:"mentions,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommentMention @提及映射(绑定UID,不是username,支持改名后自动更新显示名)
|
// CommentMention @提及映射(绑定UID + 原始用户名,支持改名后自动更新显示名)
|
||||||
type CommentMention struct {
|
type CommentMention struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
|
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
|
||||||
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
|
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
|
||||||
|
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MentionInfo @提及显示信息(前端渲染用)
|
||||||
|
type MentionInfo struct {
|
||||||
|
UID uint `json:"uid"`
|
||||||
|
Name string `json:"name"` // 当前显示名
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSearchResult @搜索用户结果
|
// UserSearchResult @搜索用户结果
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// CommunityFund 公户余额(单行表,id=1)
|
// CommunityFund 公户余额(单行表,id=1)
|
||||||
type CommunityFund struct {
|
type CommunityFund struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数)
|
Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数)
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import "time"
|
|||||||
|
|
||||||
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
|
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
|
||||||
type DailyLikeSummary struct {
|
type DailyLikeSummary struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
|
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
|
||||||
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DD(Asia/Shanghai)
|
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DD(Asia/Shanghai)
|
||||||
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
|
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
|
||||||
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
|
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,14 +32,30 @@ type PostListResult struct {
|
|||||||
|
|
||||||
// PostCreateRequest 发帖请求
|
// PostCreateRequest 发帖请求
|
||||||
type PostCreateRequest struct {
|
type PostCreateRequest struct {
|
||||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||||
Body string `json:"body" binding:"required,min=1"`
|
Body string `json:"body" binding:"required,min=1"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
PostType string `json:"post_type"`
|
||||||
|
ReprintSource string `json:"reprint_source"`
|
||||||
|
Declaration string `json:"declaration"`
|
||||||
|
ReprintProhibited bool `json:"reprint_prohibited"`
|
||||||
|
CategoryID string `json:"category_id"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
ScheduledAt string `json:"scheduled_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostUpdateRequest 编辑请求
|
// PostUpdateRequest 编辑请求
|
||||||
type PostUpdateRequest struct {
|
type PostUpdateRequest struct {
|
||||||
Title string `json:"title" binding:"required,min=1,max=200"`
|
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||||
Body string `json:"body" binding:"required,min=1"`
|
Body string `json:"body" binding:"required,min=1"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
PostType string `json:"post_type"`
|
||||||
|
ReprintSource string `json:"reprint_source"`
|
||||||
|
Declaration string `json:"declaration"`
|
||||||
|
ReprintProhibited bool `json:"reprint_prohibited"`
|
||||||
|
CategoryID string `json:"category_id"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
ScheduledAt string `json:"scheduled_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostRejectRequest 退回请求
|
// PostRejectRequest 退回请求
|
||||||
@ -58,4 +74,4 @@ type PostListQuery struct {
|
|||||||
Status string `form:"status"`
|
Status string `form:"status"`
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
PageSize int `form:"page_size"`
|
PageSize int `form:"page_size"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,20 +4,20 @@ import "time"
|
|||||||
|
|
||||||
// 域能流水类型常量
|
// 域能流水类型常量
|
||||||
const (
|
const (
|
||||||
EnergyTypeSignIn = "sign_in"
|
EnergyTypeSignIn = "sign_in"
|
||||||
EnergyTypeRename = "rename"
|
EnergyTypeRename = "rename"
|
||||||
EnergyTypeRenameRefund = "rename_refund"
|
EnergyTypeRenameRefund = "rename_refund"
|
||||||
EnergyTypeEnergize = "energize"
|
EnergyTypeEnergize = "energize"
|
||||||
EnergyTypeEnergized = "energized"
|
EnergyTypeEnergized = "energized"
|
||||||
EnergyTypeDeletePost = "delete_post"
|
EnergyTypeDeletePost = "delete_post"
|
||||||
EnergyTypeAdminAdjust = "admin_adjust"
|
EnergyTypeAdminAdjust = "admin_adjust"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnergyLog 域能流水记录
|
// EnergyLog 域能流水记录
|
||||||
type EnergyLog struct {
|
type EnergyLog struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减)
|
Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减)
|
||||||
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
|
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
|
||||||
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
|
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
|
||||||
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||||
@ -28,11 +28,11 @@ type EnergyLog struct {
|
|||||||
|
|
||||||
// EnergyLogDisplayNames 流水类型 → 中文名
|
// EnergyLogDisplayNames 流水类型 → 中文名
|
||||||
var EnergyLogDisplayNames = map[string]string{
|
var EnergyLogDisplayNames = map[string]string{
|
||||||
EnergyTypeSignIn: "每日签到",
|
EnergyTypeSignIn: "每日签到",
|
||||||
EnergyTypeRename: "改名消耗",
|
EnergyTypeRename: "改名消耗",
|
||||||
EnergyTypeRenameRefund: "改名退款",
|
EnergyTypeRenameRefund: "改名退款",
|
||||||
EnergyTypeEnergize: "赋能消耗",
|
EnergyTypeEnergize: "赋能消耗",
|
||||||
EnergyTypeEnergized: "被赋能",
|
EnergyTypeEnergized: "被赋能",
|
||||||
EnergyTypeDeletePost: "删稿消耗",
|
EnergyTypeDeletePost: "删稿消耗",
|
||||||
EnergyTypeAdminAdjust: "后台调整",
|
EnergyTypeAdminAdjust: "后台调整",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
// time imported by common.go
|
||||||
|
|
||||||
// Folder 收藏夹
|
// Folder 收藏夹
|
||||||
type Folder struct {
|
type Folder struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
|
||||||
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Description string `gorm:"type:varchar(200);default:''" json:"description"`
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
IsPublic bool `gorm:"default:false" json:"is_public"`
|
Description string `gorm:"type:varchar(200);default:''" json:"description"`
|
||||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
IsPublic bool `gorm:"default:false" json:"is_public"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
|
|
||||||
// 联表查询填充(非数据库字段)
|
// 联表查询填充(非数据库字段)
|
||||||
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
|
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
|
||||||
@ -19,11 +18,11 @@ type Folder struct {
|
|||||||
|
|
||||||
// FolderItem 收藏记录
|
// FolderItem 收藏记录
|
||||||
type FolderItem struct {
|
type FolderItem struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
FolderID uint `gorm:"index;not null" json:"folder_id"`
|
|
||||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
FolderID uint `gorm:"index;not null" json:"folder_id"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
|
||||||
// 联表查询填充
|
// 联表查询填充
|
||||||
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
|
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
|
||||||
|
|||||||
@ -4,18 +4,18 @@ import "time"
|
|||||||
|
|
||||||
// 公户流水类型常量
|
// 公户流水类型常量
|
||||||
const (
|
const (
|
||||||
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
|
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
|
||||||
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
|
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
|
||||||
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
|
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
|
||||||
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
|
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
|
||||||
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
|
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
|
||||||
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
|
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
|
||||||
)
|
)
|
||||||
|
|
||||||
// FundLog 公户流水记录
|
// FundLog 公户流水记录
|
||||||
type FundLog struct {
|
type FundLog struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10)
|
Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10)
|
||||||
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
|
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
|
||||||
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
|
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
|
||||||
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||||
|
|||||||
@ -2,17 +2,18 @@ package model
|
|||||||
|
|
||||||
// 消息/通知类型常量
|
// 消息/通知类型常量
|
||||||
const (
|
const (
|
||||||
NotifyAuditApproved = "audit_approved"
|
NotifyAuditApproved = "audit_approved"
|
||||||
NotifyAuditRejected = "audit_rejected"
|
NotifyAuditRejected = "audit_rejected"
|
||||||
NotifyPostApproved = "post_approved"
|
NotifyPostApproved = "post_approved"
|
||||||
NotifyPostRejected = "post_rejected"
|
NotifyPostRejected = "post_rejected"
|
||||||
NotifyPostLocked = "post_locked"
|
NotifyPostLocked = "post_locked"
|
||||||
NotifyPostUnlocked = "post_unlocked"
|
NotifyPostUnlocked = "post_unlocked"
|
||||||
NotifyLevelUp = "level_up"
|
NotifyLevelUp = "level_up"
|
||||||
NotifyComment = "comment"
|
NotifyComment = "comment"
|
||||||
NotifyCommentReply = "comment_reply"
|
NotifyCommentReply = "comment_reply"
|
||||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||||
NotifyFollow = "follow" // 关注通知
|
NotifyFollow = "follow" // 关注通知
|
||||||
|
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification 消息/通知模型
|
// Notification 消息/通知模型
|
||||||
@ -39,6 +40,7 @@ var NotifyTypeNames = map[string]string{
|
|||||||
NotifyLevelUp: "等级提升",
|
NotifyLevelUp: "等级提升",
|
||||||
NotifyComment: "评论",
|
NotifyComment: "评论",
|
||||||
NotifyCommentReply: "回复",
|
NotifyCommentReply: "回复",
|
||||||
|
NotifyCommentMention: "@ 提及",
|
||||||
NotifyLikeAggregated: "点赞",
|
NotifyLikeAggregated: "点赞",
|
||||||
NotifyFollow: "关注",
|
NotifyFollow: "关注",
|
||||||
}
|
}
|
||||||
@ -54,6 +56,7 @@ var NotifyCategory = map[string]string{
|
|||||||
NotifyLevelUp: "system",
|
NotifyLevelUp: "system",
|
||||||
NotifyComment: "mention",
|
NotifyComment: "mention",
|
||||||
NotifyCommentReply: "mention",
|
NotifyCommentReply: "mention",
|
||||||
|
NotifyCommentMention: "mention",
|
||||||
NotifyLikeAggregated: "like",
|
NotifyLikeAggregated: "like",
|
||||||
NotifyFollow: "follow",
|
NotifyFollow: "follow",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,42 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Post 帖子/文章模型
|
// Post 帖子/文章模型
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
BaseModel
|
||||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
|
||||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||||
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
|
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
||||||
Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
|
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
|
||||||
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
|
||||||
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
|
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
||||||
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
|
||||||
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
||||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||||
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
|
||||||
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
// 文章属性
|
||||||
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
|
||||||
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private
|
||||||
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint
|
||||||
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
Declaration string `gorm:"type:varchar(30);default:''" json:"declaration,omitempty"` // 创作声明
|
||||||
CreatedAt time.Time `json:"created_at"`
|
ReprintProhibited bool `gorm:"default:false" json:"reprint_prohibited"` // 禁止转载
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
PinType string `gorm:"type:varchar(10);default:''" json:"pin_type,omitempty"` // category / global
|
||||||
|
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 置顶时间
|
||||||
|
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` // 定时发布时间
|
||||||
|
|
||||||
|
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||||
|
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
||||||
|
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
||||||
|
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
||||||
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
||||||
|
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
||||||
|
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
||||||
|
|
||||||
// 非数据库字段(联表查询填充)
|
// 非数据库字段(联表查询填充)
|
||||||
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
|
|
||||||
// 从 JOIN 查询的 users.username AS author_name 别名中读取
|
|
||||||
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,3 +48,32 @@ const (
|
|||||||
PostStatusRejected = "rejected"
|
PostStatusRejected = "rejected"
|
||||||
PostStatusLocked = "locked"
|
PostStatusLocked = "locked"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 可见性常量
|
||||||
|
const (
|
||||||
|
VisibilityPublic = "public"
|
||||||
|
VisibilityPrivate = "private"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 文章类型常量
|
||||||
|
const (
|
||||||
|
PostTypeOriginal = "original"
|
||||||
|
PostTypeReprint = "reprint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 置顶类型常量
|
||||||
|
const (
|
||||||
|
PinTypeCategory = "category"
|
||||||
|
PinTypeGlobal = "global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeclarationLabels 创作声明名称映射
|
||||||
|
var DeclarationLabels = map[string]string{
|
||||||
|
"ai_generated": "该内容使用AI辅助创作",
|
||||||
|
"fictional": "该内容包含虚构情节",
|
||||||
|
"speculative": "该内容包含猜测/推测性内容",
|
||||||
|
"disturbing": "该内容可能包含令人不适的内容",
|
||||||
|
"spoiler": "该内容包含剧透内容",
|
||||||
|
"controversial": "该内容可能涉及争议性话题",
|
||||||
|
"opinion": "本文仅代表作者个人观点",
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user