refactor: Gitea 式目录改组,移除本分支 SPA 与杂项产物
将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1222
routers/api/api.go
Normal file
1222
routers/api/api.go
Normal file
File diff suppressed because it is too large
Load Diff
218
routers/api/economy.go
Normal file
218
routers/api/economy.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// APIMePoints 余额与流水
|
||||
func (h *Handlers) APIMePoints(c *gin.Context) {
|
||||
uid := h.currentUserID(c)
|
||||
user, err := h.User.GetByID(uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
rows, total, err := h.Points.ListLedger(uid, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
checkIn, _ := h.Points.GetCheckInStatus(uid)
|
||||
lottery, _ := h.Points.GetLotteryStatus(uid)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"points": user.Points,
|
||||
"creator_income_total": user.CreatorIncomeTotal,
|
||||
"ledger": rows,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
"check_in": checkIn,
|
||||
"lottery": lottery,
|
||||
})
|
||||
}
|
||||
|
||||
// APIMeCheckInGet 今日签到状态
|
||||
func (h *Handlers) APIMeCheckInGet(c *gin.Context) {
|
||||
st, err := h.Points.GetCheckInStatus(h.currentUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"check_in": st})
|
||||
}
|
||||
|
||||
// APIMeCheckIn 每日签到
|
||||
func (h *Handlers) APIMeCheckIn(c *gin.Context) {
|
||||
st, err := h.Points.CheckIn(h.currentUserID(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrAlreadyCheckedIn) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, _ := h.User.GetByID(h.currentUserID(c))
|
||||
pts := 0
|
||||
if user != nil {
|
||||
pts = user.Points
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "签到成功", "check_in": st, "points": pts})
|
||||
}
|
||||
|
||||
// APIMeLottery GET 状态 / POST 抽奖
|
||||
func (h *Handlers) APIMeLotteryGet(c *gin.Context) {
|
||||
st, err := h.Points.GetLotteryStatus(h.currentUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"lottery": st})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIMeLotteryDraw(c *gin.Context) {
|
||||
st, err := h.Points.DrawLottery(h.currentUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, _ := h.User.GetByID(h.currentUserID(c))
|
||||
pts := 0
|
||||
if user != nil {
|
||||
pts = user.Points
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "抽奖完成", "lottery": st, "points": pts})
|
||||
}
|
||||
|
||||
// APIUnlockPostBlock 积分解锁付费块
|
||||
func (h *Handlers) APIUnlockPostBlock(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
BlockKey string `json:"block_key"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.BlockKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
|
||||
return
|
||||
}
|
||||
res, err := service.UnlockPointsBlock(h.currentUserID(c), uint(id), req.BlockKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "解锁成功", "unlock": res})
|
||||
}
|
||||
|
||||
// APIAdminVerifyUser 认证开关
|
||||
func (h *Handlers) APIAdminVerifyUser(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := service.SetVerified(uint(id), req.Verified); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已取消认证"
|
||||
if req.Verified {
|
||||
msg = "已认证"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "verified": req.Verified})
|
||||
}
|
||||
|
||||
// APIAdminSetUserLevel 设等级
|
||||
func (h *Handlers) APIAdminSetUserLevel(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := service.SetUserLevel(uint(id), req.Level); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "等级已更新", "level": req.Level, "exp": model.ExpForLevel(req.Level)})
|
||||
}
|
||||
|
||||
// APIAdminAdjustPoints 调积分
|
||||
func (h *Handlers) APIAdminAdjustPoints(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Delta int `json:"delta"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
bal, err := h.Points.AdminAdjust(uint(id), req.Delta, req.Note)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "积分已调整", "points": bal})
|
||||
}
|
||||
|
||||
// APIAdminListBadges 徽章定义列表
|
||||
func (h *Handlers) APIAdminListBadges(c *gin.Context) {
|
||||
rows, err := h.Badge.ListDefs(true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"badges": rows})
|
||||
}
|
||||
|
||||
// APIAdminUpsertBadge 创建/更新徽章定义
|
||||
func (h *Handlers) APIAdminUpsertBadge(c *gin.Context) {
|
||||
var def model.BadgeDef
|
||||
if err := c.ShouldBindJSON(&def); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Badge.UpsertDef(&def); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已保存", "badge": def})
|
||||
}
|
||||
|
||||
// APIAdminAwardBadge 颁发/收回限定徽章
|
||||
func (h *Handlers) APIAdminAwardBadge(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
BadgeID uint `json:"badge_id"`
|
||||
Revoke bool `json:"revoke"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.BadgeID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Revoke {
|
||||
if err := h.Badge.Revoke(uint(id), req.BadgeID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已收回徽章"})
|
||||
return
|
||||
}
|
||||
if err := h.Badge.AwardLimited(uint(id), req.BadgeID, h.currentUserID(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已颁发徽章"})
|
||||
}
|
||||
281
routers/api/friend_link.go
Normal file
281
routers/api/friend_link.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIApplyFriendLink 提交友情链接申请
|
||||
func (h *Handlers) APIApplyFriendLink(c *gin.Context) {
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Logo string `json:"logo"`
|
||||
LinkOnHomepage bool `json:"link_on_homepage"`
|
||||
ReciprocalPageURL string `json:"reciprocal_page_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
result, err := h.FriendLinkApply.Create(service.FriendLinkApplyInput{
|
||||
UserID: uid,
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
Logo: req.Logo,
|
||||
LinkOnHomepage: req.LinkOnHomepage,
|
||||
ReciprocalPageURL: req.ReciprocalPageURL,
|
||||
OurSiteURL: h.publicBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp := gin.H{
|
||||
"message": friendLinkApplySubmittedMessage(h.Settings.FriendLinkReciprocalCheckEnabled(), false),
|
||||
"apply": result.Apply,
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// APIUploadFriendLinkLogo 上传友链申请 LOGO
|
||||
func (h *Handlers) APIUploadFriendLinkLogo(c *gin.Context) {
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("logo")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 LOGO 图片"})
|
||||
return
|
||||
}
|
||||
maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024
|
||||
if file.Size > maxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"})
|
||||
return
|
||||
}
|
||||
url, err := service.SaveUploadedImage(
|
||||
h.Store,
|
||||
file,
|
||||
service.UploadCategorySite,
|
||||
fmt.Sprintf("fl_%d", uid),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LOGO 已上传", "url": url})
|
||||
}
|
||||
|
||||
// APIAdminFriendLinkApplies 管理员友链申请列表
|
||||
func (h *Handlers) APIAdminFriendLinkApplies(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
status := strings.TrimSpace(c.DefaultQuery("status", "pending"))
|
||||
list, total, err := h.FriendLinkApply.ListAdmin(service.FriendLinkApplyListQuery{
|
||||
Page: page, Size: size, Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pending, _ := h.FriendLinkApply.PendingCount()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"applies": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pending_count": pending,
|
||||
"reciprocal_check_enabled": h.Settings.FriendLinkReciprocalCheckEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUpdateFriendLinkSettings 更新友链相关开关(回链检测 / 入口展示)
|
||||
func (h *Handlers) APIAdminUpdateFriendLinkSettings(c *gin.Context) {
|
||||
var req struct {
|
||||
ReciprocalCheckEnabled *bool `json:"reciprocal_check_enabled"`
|
||||
NavShowFriendLinks *bool `json:"nav_show_friend_links"`
|
||||
FooterShowFriendLinks *bool `json:"footer_show_friend_links"`
|
||||
AsideShowFriendLinks *bool `json:"aside_show_friend_links"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.ReciprocalCheckEnabled == nil && req.NavShowFriendLinks == nil &&
|
||||
req.FooterShowFriendLinks == nil && req.AsideShowFriendLinks == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.ReciprocalCheckEnabled != nil {
|
||||
if err := h.Settings.SetFriendLinkReciprocalCheckEnabled(*req.ReciprocalCheckEnabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NavShowFriendLinks != nil {
|
||||
if err := h.Settings.SetNavShowFriendLinks(*req.NavShowFriendLinks); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.FooterShowFriendLinks != nil {
|
||||
if err := h.Settings.SetFooterShowFriendLinks(*req.FooterShowFriendLinks); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.AsideShowFriendLinks != nil {
|
||||
if err := h.Settings.SetAsideFriendLinksEnabled(*req.AsideShowFriendLinks); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg := "设置已保存"
|
||||
if req.ReciprocalCheckEnabled != nil && req.NavShowFriendLinks == nil &&
|
||||
req.FooterShowFriendLinks == nil && req.AsideShowFriendLinks == nil {
|
||||
if *req.ReciprocalCheckEnabled {
|
||||
msg = "已开启回链检测"
|
||||
} else {
|
||||
msg = "已关闭回链检测"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": msg,
|
||||
"reciprocal_check_enabled": h.Settings.FriendLinkReciprocalCheckEnabled(),
|
||||
"nav_show_friend_links": h.Settings.NavShowFriendLinks(),
|
||||
"footer_show_friend_links": h.Settings.FooterShowFriendLinks(),
|
||||
"aside_show_friend_links": h.Settings.AsideShowFriendLinks(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminApproveFriendLinkApply 通过友链申请
|
||||
func (h *Handlers) APIAdminApproveFriendLinkApply(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
apply, err := h.FriendLinkApply.Approve(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已通过并加入友情链接", "apply": apply})
|
||||
}
|
||||
|
||||
// APIAdminRejectFriendLinkApply 拒绝友链申请
|
||||
func (h *Handlers) APIAdminRejectFriendLinkApply(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
apply, err := h.FriendLinkApply.Reject(uint(id), req.Note)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已拒绝申请", "apply": apply})
|
||||
}
|
||||
|
||||
// APIMyFriendLinkApplies 当前用户的友链申请列表
|
||||
func (h *Handlers) APIMyFriendLinkApplies(c *gin.Context) {
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
list, err := h.FriendLinkApply.ListMine(uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"applies": list})
|
||||
}
|
||||
|
||||
// APICancelFriendLinkApply 撤销待审友链申请
|
||||
func (h *Handlers) APICancelFriendLinkApply(c *gin.Context) {
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.FriendLinkApply.Cancel(uid, uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已撤销申请"})
|
||||
}
|
||||
|
||||
// APIUpdateFriendLinkApply 修改并重新提交友链申请
|
||||
func (h *Handlers) APIUpdateFriendLinkApply(c *gin.Context) {
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Logo string `json:"logo"`
|
||||
LinkOnHomepage bool `json:"link_on_homepage"`
|
||||
ReciprocalPageURL string `json:"reciprocal_page_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
result, err := h.FriendLinkApply.Update(uid, uint(id), service.FriendLinkApplyInput{
|
||||
UserID: uid,
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
Logo: req.Logo,
|
||||
LinkOnHomepage: req.LinkOnHomepage,
|
||||
ReciprocalPageURL: req.ReciprocalPageURL,
|
||||
OurSiteURL: h.publicBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp := gin.H{
|
||||
"message": friendLinkApplySubmittedMessage(h.Settings.FriendLinkReciprocalCheckEnabled(), true),
|
||||
"apply": result.Apply,
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// APIAdminRecheckFriendLinkApply 管理员重新检测回链
|
||||
func (h *Handlers) APIAdminRecheckFriendLinkApply(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
apply, err := h.FriendLinkApply.RecheckReciprocal(uint(id), h.publicBaseURL(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已开始重新检测回链", "apply": apply})
|
||||
}
|
||||
|
||||
func friendLinkApplySubmittedMessage(checkEnabled, isUpdate bool) string {
|
||||
if isUpdate {
|
||||
if checkEnabled {
|
||||
return "申请已更新,回链检测将在后台进行"
|
||||
}
|
||||
return "申请已更新"
|
||||
}
|
||||
if checkEnabled {
|
||||
return "申请已提交,回链检测将在后台进行"
|
||||
}
|
||||
return "申请已提交"
|
||||
}
|
||||
597
routers/api/handlers.go
Normal file
597
routers/api/handlers.go
Normal file
@@ -0,0 +1,597 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// Handlers 聚合所有 HTTP 处理器
|
||||
type Handlers struct {
|
||||
Cfg *config.Config
|
||||
Store *service.UploadStore
|
||||
Auth *service.AuthService
|
||||
User *service.UserService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Message *service.MessageService
|
||||
Notify *service.NotifyService
|
||||
Report *service.ReportService
|
||||
Backup *service.BackupService
|
||||
Filter *service.SensitiveFilter
|
||||
Limiter *service.RateLimiter
|
||||
Settings *service.ForumSettingsService
|
||||
Captcha *service.CaptchaService
|
||||
Mail *service.MailService
|
||||
EmailCode *service.EmailCodeService
|
||||
OIDC *service.OIDCService
|
||||
Gitea *service.GiteaService
|
||||
Points *service.PointsService
|
||||
Badge *service.BadgeService
|
||||
SitePage *service.SitePageService
|
||||
FriendLinkApply *service.FriendLinkApplyService
|
||||
}
|
||||
|
||||
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
|
||||
c.SetCookie(middleware.CookieName, token, int(service.TokenExpire.Seconds()), "/", "", false, true)
|
||||
}
|
||||
|
||||
func (h *Handlers) currentUserID(c *gin.Context) uint {
|
||||
if v, ok := c.Get(middleware.CtxUserID); ok {
|
||||
return v.(uint)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (h *Handlers) isAdmin(c *gin.Context) bool {
|
||||
if v, ok := c.Get(middleware.CtxRole); ok {
|
||||
return v == model.RoleAdmin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadCurrentUser 加载当前登录用户完整资料(含认证/积分)
|
||||
func (h *Handlers) loadCurrentUser(c *gin.Context) (*model.User, error) {
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
return nil, errors.New("未登录")
|
||||
}
|
||||
return h.User.GetByID(uid)
|
||||
}
|
||||
|
||||
func (h *Handlers) skipsModeration(c *gin.Context) bool {
|
||||
u, err := h.loadCurrentUser(c)
|
||||
if err != nil {
|
||||
return h.isAdmin(c)
|
||||
}
|
||||
return u.SkipsModeration()
|
||||
}
|
||||
|
||||
func (h *Handlers) parseGuestCommentIDs(c *gin.Context) []uint {
|
||||
raw := c.Query("my_ids")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []uint
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(part, 10, 64)
|
||||
if err == nil && v > 0 {
|
||||
ids = append(ids, uint(v))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func calcTotalPages(total int64, size int) int {
|
||||
if total == 0 {
|
||||
return 1
|
||||
}
|
||||
pages := int(total) / size
|
||||
if int(total)%size > 0 {
|
||||
pages++
|
||||
}
|
||||
if pages < 1 {
|
||||
return 1
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
func (h *Handlers) APICaptcha(c *gin.Context) {
|
||||
id, svg, err := h.Captcha.Generate()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)),
|
||||
})
|
||||
}
|
||||
|
||||
// APIRegisterConfig 注册页所需公开配置
|
||||
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
||||
userCount := h.Auth.UserCount()
|
||||
mailReady := h.Settings.MailReady()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"is_first_user": userCount == 0,
|
||||
"mail_ready": mailReady,
|
||||
"require_email_code": mailReady,
|
||||
"register_open": userCount == 0 || mailReady,
|
||||
"email_code_len": service.EmailCodeLen,
|
||||
})
|
||||
}
|
||||
|
||||
// APISendRegisterEmailCode 发送注册邮箱验证码
|
||||
func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" form:"email" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if !h.Settings.MailReady() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.EmailCode.SendRegisterCode(req.Email); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
|
||||
}
|
||||
|
||||
// APISendResetEmailCode 发送重置密码验证码
|
||||
func (h *Handlers) APISendResetEmailCode(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" form:"email" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if !h.Settings.MailReady() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.EmailCode.SendResetCode(req.Email); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "若该邮箱已注册,验证码将发送到邮箱"})
|
||||
}
|
||||
|
||||
// APIResetPassword 邮箱验证码重置密码
|
||||
func (h *Handlers) APIResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" form:"email" binding:"required"`
|
||||
EmailCode string `json:"email_code" form:"email_code" binding:"required"`
|
||||
NewPassword string `json:"new_password" form:"new_password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if !h.Settings.MailReady() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
|
||||
return
|
||||
}
|
||||
if !h.EmailCode.VerifyPurpose(service.EmailCodePurposeReset, req.Email, req.EmailCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.User.ResetPasswordByEmail(req.Email, req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码已重置,请使用新密码登录"})
|
||||
}
|
||||
|
||||
// APISearchUsers 用户搜索(@补全)
|
||||
func (h *Handlers) APISearchUsers(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"users": []any{}})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
|
||||
users, err := h.User.SearchUsersBrief(q, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"nickname": u.Nickname,
|
||||
"avatar": u.Avatar,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"users": out})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIRegister(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" form:"username" binding:"required"`
|
||||
Password string `json:"password" form:"password" binding:"required"`
|
||||
Nickname string `json:"nickname" form:"nickname"`
|
||||
Email string `json:"email" form:"email" binding:"required"`
|
||||
EmailCode string `json:"email_code" form:"email_code"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
userCount := h.Auth.UserCount()
|
||||
mailReady := h.Settings.MailReady()
|
||||
if userCount > 0 && !mailReady {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrRegisterClosed.Error()})
|
||||
return
|
||||
}
|
||||
if mailReady {
|
||||
if !h.EmailCode.Verify(req.Email, req.EmailCode) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.Auth.Register(req.Username, req.Password, req.Nickname, req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
h.setAuthCookie(c, token)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID})
|
||||
}
|
||||
|
||||
func (h *Handlers) APILogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" form:"username" binding:"required"`
|
||||
Password string `json:"password" form:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.setAuthCookie(c, token)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "登录成功", "user": gin.H{"id": user.ID, "nickname": user.Nickname}})
|
||||
}
|
||||
|
||||
func (h *Handlers) APILogout(c *gin.Context) {
|
||||
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已退出"})
|
||||
}
|
||||
|
||||
// APIProfileStats 当前用户活动统计(发帖 / 评论 / 收藏 / 获赞)
|
||||
func (h *Handlers) APIProfileStats(c *gin.Context) {
|
||||
st, err := h.User.ActivityStats(h.currentUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"stats": st})
|
||||
}
|
||||
|
||||
// APIUserPublic 公开用户主页(资料 + 公开统计)
|
||||
func (h *Handlers) APIUserPublic(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效用户"})
|
||||
return
|
||||
}
|
||||
user, err := h.User.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
st, err := h.User.ActivityStats(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 收藏数仅本人可见
|
||||
viewerID := h.currentUserID(c)
|
||||
if viewerID != user.ID {
|
||||
st.FavoriteCount = 0
|
||||
}
|
||||
view := user.ToPublic()
|
||||
if h.Badge != nil {
|
||||
_ = h.Badge.EvaluateAuto(user.ID)
|
||||
if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
|
||||
view.Badges = service.BadgeViews(badges, 0)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": view,
|
||||
"stats": st,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdateProfile(c *gin.Context) {
|
||||
nickname := c.PostForm("nickname")
|
||||
if err := h.User.UpdateNickname(h.currentUserID(c), nickname); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, _ := h.User.GetByID(h.currentUserID(c))
|
||||
var userView any
|
||||
if user != nil {
|
||||
userView = user.ToSelf()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": userView})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdateSignature(c *gin.Context) {
|
||||
signature := c.PostForm("signature")
|
||||
if err := h.User.UpdateSignature(h.currentUserID(c), signature); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, _ := h.User.GetByID(h.currentUserID(c))
|
||||
var userView any
|
||||
if user != nil {
|
||||
userView = user.ToSelf()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "签名已更新", "user": userView})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdatePassword(c *gin.Context) {
|
||||
oldPass := c.PostForm("old_password")
|
||||
newPass := c.PostForm("new_password")
|
||||
if err := h.User.UpdatePassword(h.currentUserID(c), oldPass, newPass); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码已修改"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUploadAvatar(c *gin.Context) {
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"})
|
||||
return
|
||||
}
|
||||
maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024
|
||||
if file.Size > maxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件过大"})
|
||||
return
|
||||
}
|
||||
url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Store)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "头像已更新", "avatar": url})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUploadPostImage(c *gin.Context) {
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
|
||||
return
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
url, err := service.SaveUploadedImage(
|
||||
h.Store,
|
||||
file,
|
||||
service.UploadCategoryPosts,
|
||||
fmt.Sprintf("%d", uid),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "图片已上传", "url": url})
|
||||
}
|
||||
|
||||
func (h *Handlers) APICreatePost(c *gin.Context) {
|
||||
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
||||
title := c.PostForm("title")
|
||||
content := c.PostForm("content")
|
||||
tags := c.PostForm("tags")
|
||||
postType := c.PostForm("post_type")
|
||||
skip := h.skipsModeration(c)
|
||||
post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, skip)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
extras := service.ParsePostExtrasFromForm(
|
||||
c.PostForm("poll_options"),
|
||||
c.PostForm("bounty_points"),
|
||||
c.PostForm("lottery_winner_count"),
|
||||
)
|
||||
if post.PostType == model.PostTypePoll || post.PostType == model.PostTypeBounty || post.PostType == model.PostTypeLottery {
|
||||
if err := service.FinalizeSpecialPostCreate(post, h.currentUserID(c), extras); err != nil {
|
||||
_ = h.Post.Delete(h.currentUserID(c), post.ID, true)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
msg := "发帖成功"
|
||||
if post.Status == model.ContentStatusPending {
|
||||
msg = "已提交审核,通过后将公开显示"
|
||||
if h.Notify != nil {
|
||||
h.Notify.AsyncNotifyPendingPost(post)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": post.ID, "status": post.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdatePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
||||
isAdmin := h.isAdmin(c)
|
||||
skip := h.skipsModeration(c)
|
||||
err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin, skip,
|
||||
c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 非免审用户修改后重新进入审核
|
||||
if !skip && h.Notify != nil {
|
||||
if post, getErr := h.Post.FindByID(uint(id)); getErr == nil {
|
||||
h.Notify.AsyncNotifyPendingPost(post)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已更新"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIDeletePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.Delete(h.currentUserID(c), uint(id), h.isAdmin(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已移入回收站"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIToggleLike(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
liked, err := h.Post.ToggleLike(h.currentUserID(c), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var post model.Post
|
||||
model.DB.First(&post, id)
|
||||
c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": post.LikeCount})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIToggleCommentLike(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
liked, likeCount, err := h.Comment.ToggleLike(h.currentUserID(c), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": likeCount})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIToggleFavorite(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
faved, err := h.Post.ToggleFavorite(h.currentUserID(c), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"favorited": faved})
|
||||
}
|
||||
|
||||
// APISetQuestionResolved 标记问答帖已解决 / 未解决
|
||||
func (h *Handlers) APISetQuestionResolved(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
resolved := c.PostForm("resolved") == "1" || c.PostForm("resolved") == "true"
|
||||
if err := h.Post.SetQuestionResolved(h.currentUserID(c), uint(id), h.isAdmin(c), resolved); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已标记为未解决"
|
||||
if resolved {
|
||||
msg = "已标记为已解决"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "question_resolved": resolved})
|
||||
}
|
||||
|
||||
func (h *Handlers) APICreateComment(c *gin.Context) {
|
||||
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
content := c.PostForm("content")
|
||||
var replyTo *uint
|
||||
if rt := c.PostForm("reply_to"); rt != "" {
|
||||
v, _ := strconv.ParseUint(rt, 10, 64)
|
||||
u := uint(v)
|
||||
replyTo = &u
|
||||
}
|
||||
isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "true"
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后评论"})
|
||||
return
|
||||
}
|
||||
|
||||
in := service.CommentCreateInput{
|
||||
UserID: uid,
|
||||
PostID: uint(postID),
|
||||
Content: content,
|
||||
ReplyTo: replyTo,
|
||||
IsPrivate: isPrivate,
|
||||
}
|
||||
|
||||
comment, err := h.Comment.Create(in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "评论成功"
|
||||
if h.Notify != nil {
|
||||
switch comment.Status {
|
||||
case model.ContentStatusPublished:
|
||||
h.Notify.AsyncNotifyCommentPublished(comment)
|
||||
h.Notify.AsyncNotifyCommentMentions(comment)
|
||||
case model.ContentStatusPending:
|
||||
msg = "评论已提交,审核通过后公开显示"
|
||||
h.Notify.AsyncNotifyPendingComment(comment)
|
||||
}
|
||||
} else if comment.Status == model.ContentStatusPending {
|
||||
msg = "评论已提交,审核通过后公开显示"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "floor": comment.Floor, "id": comment.ID, "status": comment.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIDeleteComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Comment.Delete(h.currentUserID(c), uint(id), h.isAdmin(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
content := c.PostForm("content")
|
||||
skip := h.skipsModeration(c)
|
||||
saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), skip, content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "评论已更新"
|
||||
status := ""
|
||||
if comment, e := h.Comment.GetByID(uint(id)); e == nil {
|
||||
status = comment.Status
|
||||
if status == model.ContentStatusPending && !h.isAdmin(c) {
|
||||
msg = "评论已更新,审核通过后公开显示"
|
||||
}
|
||||
if enteredPending && h.Notify != nil {
|
||||
h.Notify.AsyncNotifyPendingComment(comment)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "content": saved, "status": status})
|
||||
}
|
||||
70
routers/api/media.go
Normal file
70
routers/api/media.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIAdminMedia 列出媒体资源
|
||||
func (h *Handlers) APIAdminMedia(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "上传存储未初始化"})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "24"))
|
||||
category := c.DefaultQuery("category", "all")
|
||||
query := c.Query("q")
|
||||
result, err := h.Store.ListMedia(category, query, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// APIAdminDeleteMedia 批量删除媒体
|
||||
func (h *Handlers) APIAdminDeleteMedia(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "上传存储未初始化"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
URLs []string `json:"urls"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
urls := make([]string, 0, len(req.URLs)+1)
|
||||
for _, u := range req.URLs {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
}
|
||||
if u := strings.TrimSpace(req.URL); u != "" {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要删除的文件"})
|
||||
return
|
||||
}
|
||||
if len(urls) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "单次最多删除 100 个文件"})
|
||||
return
|
||||
}
|
||||
n, err := h.Store.DeleteMedia(urls)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已删除 " + strconv.Itoa(n) + " 项媒体",
|
||||
"deleted": n,
|
||||
})
|
||||
}
|
||||
170
routers/api/message.go
Normal file
170
routers/api/message.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// APIMessageConversations 会话列表(按对方聚合)
|
||||
func (h *Handlers) APIMessageConversations(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
|
||||
list, total, err := h.Message.ListConversations(service.ConversationListQuery{
|
||||
UserID: h.currentUserID(c),
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversations": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
// APIConversationMessages 某会话内消息
|
||||
func (h *Handlers) APIConversationMessages(c *gin.Context) {
|
||||
peerID, err := strconv.ParseUint(c.Param("peerId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的会话"})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||
before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64)
|
||||
uid := h.currentUserID(c)
|
||||
|
||||
list, total, err := h.Message.ListConversationMessages(service.ConversationMessagesQuery{
|
||||
UserID: uid,
|
||||
PeerID: uint(peerID),
|
||||
Page: page,
|
||||
Size: size,
|
||||
Before: uint(before),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 首次打开(非向上翻页)时标已读
|
||||
if before == 0 {
|
||||
_ = h.Message.MarkConversationRead(uid, uint(peerID))
|
||||
for i := range list {
|
||||
if list[i].ToUserID == uid {
|
||||
list[i].IsRead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var peer *model.User
|
||||
if peerID > 0 {
|
||||
var u model.User
|
||||
if err := model.DB.First(&u, uint(peerID)).Error; err == nil {
|
||||
peer = &u
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": list,
|
||||
"total": total,
|
||||
"peer_user_id": uint(peerID),
|
||||
"peer_user": peer,
|
||||
"is_system": peerID == 0,
|
||||
})
|
||||
}
|
||||
|
||||
// APIMarkConversationRead 将会话标为已读
|
||||
func (h *Handlers) APIMarkConversationRead(c *gin.Context) {
|
||||
peerID, err := strconv.ParseUint(c.Param("peerId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的会话"})
|
||||
return
|
||||
}
|
||||
if err := h.Message.MarkConversationRead(h.currentUserID(c), uint(peerID)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已标为已读"})
|
||||
}
|
||||
|
||||
// APIMessageUnreadCount 未读私信数(含私信/通知分项)
|
||||
func (h *Handlers) APIMessageUnreadCount(c *gin.Context) {
|
||||
total, dm, notify, err := h.Message.UnreadCounts(h.currentUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"count": total,
|
||||
"dm_count": dm,
|
||||
"notify_count": notify,
|
||||
})
|
||||
}
|
||||
|
||||
// APIMessageNotifications 系统通知列表
|
||||
func (h *Handlers) APIMessageNotifications(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
|
||||
kind := c.Query("kind")
|
||||
uid := h.currentUserID(c)
|
||||
list, total, err := h.Message.ListNotifications(uid, page, size, kind)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"kind": kind,
|
||||
})
|
||||
}
|
||||
|
||||
// APIMarkNotificationsRead 系统通知全部已读
|
||||
func (h *Handlers) APIMarkNotificationsRead(c *gin.Context) {
|
||||
if err := h.Message.MarkNotificationsRead(h.currentUserID(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "通知已全部标为已读"})
|
||||
}
|
||||
|
||||
// APISendMessage 发送私信
|
||||
func (h *Handlers) APISendMessage(c *gin.Context) {
|
||||
var req struct {
|
||||
ToUserID uint `json:"to_user_id"`
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
msg, err := h.Message.Send(service.MessageSendInput{
|
||||
FromUserID: h.currentUserID(c),
|
||||
ToUserID: req.ToUserID,
|
||||
Subject: req.Subject,
|
||||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg})
|
||||
}
|
||||
|
||||
// APIMarkAllMessagesRead 全部已读
|
||||
func (h *Handlers) APIMarkAllMessagesRead(c *gin.Context) {
|
||||
if err := h.Message.MarkAllRead(h.currentUserID(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已全部标为已读"})
|
||||
}
|
||||
224
routers/api/oidc.go
Normal file
224
routers/api/oidc.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// OIDCDiscovery OpenID Provider 元数据
|
||||
func (h *Handlers) OIDCDiscovery(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.Discovery()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCJWKS JSON Web Key Set
|
||||
func (h *Handlers) OIDCJWKS(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.JWKS()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCAuthorize 授权端点:已登录则静默发码;未登录跳转论坛登录
|
||||
func (h *Handlers) OIDCAuthorize(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.String(http.StatusServiceUnavailable, "OIDC 未配置,请在管理后台「系统设置 → OIDC / SSO」启用并创建 OAuth 应用")
|
||||
return
|
||||
}
|
||||
|
||||
req := service.AuthorizeRequest{
|
||||
ClientID: c.Query("client_id"),
|
||||
RedirectURI: c.Query("redirect_uri"),
|
||||
ResponseType: c.Query("response_type"),
|
||||
Scope: c.Query("scope"),
|
||||
State: c.Query("state"),
|
||||
Nonce: c.Query("nonce"),
|
||||
CodeChallenge: c.Query("code_challenge"),
|
||||
CodeChallengeMethod: c.Query("code_challenge_method"),
|
||||
}
|
||||
|
||||
if err := h.OIDC.ValidateAuthorize(req); err != nil {
|
||||
// redirect_uri 未通过校验时不能重定向,避免开放重定向
|
||||
if errors.Is(err, service.ErrOIDCInvalidRedirect) || errors.Is(err, service.ErrOIDCInvalidClient) {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
from := c.Request.URL.RequestURI()
|
||||
c.Redirect(http.StatusFound, "/login?from="+url.QueryEscape(from))
|
||||
return
|
||||
}
|
||||
|
||||
callback, err := h.OIDC.IssueAuthCode(uid, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOIDCUserBanned) {
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "server_error", err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, callback)
|
||||
}
|
||||
|
||||
func (h *Handlers) oidcErrorRedirect(c *gin.Context, redirectURI, state, code, desc string) {
|
||||
if redirectURI == "" {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", code)
|
||||
q.Set("error_description", desc)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
c.Redirect(http.StatusFound, u.String())
|
||||
}
|
||||
|
||||
// OIDCToken 令牌端点
|
||||
func (h *Handlers) OIDCToken(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "temporarily_unavailable", "error_description": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID, clientSecret := c.PostForm("client_id"), c.PostForm("client_secret")
|
||||
if clientID == "" && clientSecret == "" {
|
||||
if id, secret, ok := parseBasicAuth(c.GetHeader("Authorization")); ok {
|
||||
clientID, clientSecret = id, secret
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := h.OIDC.ExchangeCode(service.TokenRequest{
|
||||
GrantType: c.PostForm("grant_type"),
|
||||
Code: c.PostForm("code"),
|
||||
RedirectURI: c.PostForm("redirect_uri"),
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
CodeVerifier: c.PostForm("code_verifier"),
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
code := "invalid_grant"
|
||||
switch {
|
||||
case errors.Is(err, service.ErrOIDCInvalidClient):
|
||||
status = http.StatusUnauthorized
|
||||
code = "invalid_client"
|
||||
case errors.Is(err, service.ErrOIDCInvalidRequest):
|
||||
code = "invalid_request"
|
||||
case errors.Is(err, service.ErrOIDCPKCEFailed):
|
||||
code = "invalid_grant"
|
||||
}
|
||||
c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// OIDCUserInfo 用户信息端点
|
||||
func (h *Handlers) OIDCUserInfo(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
token := extractBearer(c)
|
||||
if token == "" {
|
||||
c.Header("WWW-Authenticate", `Bearer`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
info, err := h.OIDC.UserInfo(token)
|
||||
if err != nil {
|
||||
c.Header("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// OIDCLogout RP-Initiated Logout:清除论坛会话并可选跳回客户端
|
||||
func (h *Handlers) OIDCLogout(c *gin.Context) {
|
||||
postLogout := c.Query("post_logout_redirect_uri")
|
||||
if postLogout == "" {
|
||||
postLogout = c.PostForm("post_logout_redirect_uri")
|
||||
}
|
||||
state := c.Query("state")
|
||||
if state == "" {
|
||||
state = c.PostForm("state")
|
||||
}
|
||||
|
||||
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
|
||||
|
||||
if h.OIDC == nil {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
target, err := h.OIDC.ResolveLogoutRedirect(postLogout, state)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, target)
|
||||
}
|
||||
|
||||
func extractBearer(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||
}
|
||||
return c.Query("access_token")
|
||||
}
|
||||
|
||||
func parseBasicAuth(header string) (user, pass string, ok bool) {
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
// client_id / client_secret 可能被 URL 编码
|
||||
uid, err1 := url.QueryUnescape(parts[0])
|
||||
sec, err2 := url.QueryUnescape(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
return uid, sec, true
|
||||
}
|
||||
163
routers/api/report.go
Normal file
163
routers/api/report.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// APICreatePostReport 举报帖子
|
||||
func (h *Handlers) APICreatePostReport(c *gin.Context) {
|
||||
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
rep, err := h.Report.Create(h.currentUserID(c), uint(postID), req.Reason, req.Detail)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep})
|
||||
}
|
||||
|
||||
// APICreateCommentReport 举报评论
|
||||
func (h *Handlers) APICreateCommentReport(c *gin.Context) {
|
||||
commentID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
rep, err := h.Report.CreateCommentReport(h.currentUserID(c), uint(commentID), req.Reason, req.Detail)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep})
|
||||
}
|
||||
|
||||
// APIAdminReports 举报列表
|
||||
func (h *Handlers) APIAdminReports(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
status := c.DefaultQuery("status", "pending")
|
||||
list, total, err := h.Report.ListAdmin(service.ReportListQuery{
|
||||
Status: status,
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pending, _ := h.Report.PendingCount()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"reports": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pending_count": pending,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminHandleReport 处理举报
|
||||
func (h *Handlers) APIAdminHandleReport(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
HandleNote string `json:"handle_note"`
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
rep, err := h.Report.Handle(service.HandleReportInput{
|
||||
ReportID: uint(id),
|
||||
HandlerID: h.currentUserID(c),
|
||||
Action: req.Action,
|
||||
HandleNote: req.HandleNote,
|
||||
RejectReason: req.RejectReason,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "处理完成", "report": rep})
|
||||
}
|
||||
|
||||
// APIAdminApprovePost 通过帖子审核
|
||||
func (h *Handlers) APIAdminApprovePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.SetStatus(uint(id), model.ContentStatusPublished); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已通过审核", "status": model.ContentStatusPublished})
|
||||
}
|
||||
|
||||
// APIAdminRejectPost 拒绝帖子并私信通知作者(标记为 rejected,不进回收站)
|
||||
func (h *Handlers) APIAdminRejectPost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写拒绝原因"})
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authorID := post.UserID
|
||||
title := post.Title
|
||||
postID := post.ID
|
||||
|
||||
if err := h.Post.SetStatus(postID, model.ContentStatusRejected); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pid := postID
|
||||
_, msgErr := h.Message.SendSystem(
|
||||
authorID,
|
||||
"帖子《"+title+"》未通过审核",
|
||||
service.FormatRejectContent(title, postID, reason),
|
||||
model.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
if msgErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "帖子已拒绝,但私信通知失败:" + msgErr.Error(),
|
||||
"notified": false,
|
||||
"status": model.ContentStatusRejected,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已拒绝该帖并私信通知作者",
|
||||
"notified": true,
|
||||
"status": model.ContentStatusRejected,
|
||||
})
|
||||
}
|
||||
637
routers/api/seo.go
Normal file
637
routers/api/seo.go
Normal file
@@ -0,0 +1,637 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/embed_static"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
var (
|
||||
seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
|
||||
)
|
||||
|
||||
const (
|
||||
seoDescMax = 160
|
||||
seoPrerenderMax = 4000
|
||||
seoSitemapLimit = 5000
|
||||
)
|
||||
|
||||
// RobotsTxt 搜索引擎抓取规则
|
||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
base := h.publicBaseURL(c)
|
||||
var b strings.Builder
|
||||
b.WriteString("User-agent: *\n")
|
||||
b.WriteString("Allow: /\n")
|
||||
b.WriteString("Disallow: /api/\n")
|
||||
b.WriteString("Disallow: /admin\n")
|
||||
b.WriteString("Disallow: /compose\n")
|
||||
b.WriteString("Disallow: /login\n")
|
||||
b.WriteString("Disallow: /register\n")
|
||||
b.WriteString("Disallow: /profile\n")
|
||||
b.WriteString("Disallow: /favorites\n")
|
||||
b.WriteString("Disallow: /oauth/\n")
|
||||
b.WriteString("Disallow: /media/\n")
|
||||
b.WriteString("Disallow: /*/edit\n")
|
||||
if base != "" {
|
||||
b.WriteString("\nSitemap: ")
|
||||
b.WriteString(base)
|
||||
b.WriteString("/sitemap.xml\n")
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// SitemapXML 公开页面站点地图
|
||||
func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
base := h.publicBaseURL(c)
|
||||
if base == "" {
|
||||
c.String(http.StatusServiceUnavailable, "未配置站点 ROOT_URL,无法生成 sitemap")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
permalink := h.Settings.Permalink()
|
||||
urls := []service.SitemapURL{
|
||||
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
|
||||
{Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"},
|
||||
{Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"},
|
||||
}
|
||||
|
||||
if boards, err := h.Board.List(); err == nil {
|
||||
for _, board := range boards {
|
||||
urls = append(urls, service.SitemapURL{
|
||||
Loc: base + service.QueryBoardHome(board.ID, permalink),
|
||||
LastMod: board.UpdatedAt.UTC(),
|
||||
ChangeFreq: "daily",
|
||||
Priority: "0.7",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if posts, e1 := h.Post.ListSitemap(seoSitemapLimit); e1 == nil {
|
||||
for _, p := range posts {
|
||||
lm := p.UpdatedAt
|
||||
if lm.IsZero() {
|
||||
lm = p.CreatedAt
|
||||
}
|
||||
urls = append(urls, service.SitemapURL{
|
||||
Loc: base + permalink.PostPath(p.ID),
|
||||
LastMod: lm.UTC(),
|
||||
ChangeFreq: "weekly",
|
||||
Priority: "0.8",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil {
|
||||
for _, u := range users {
|
||||
urls = append(urls, service.SitemapURL{
|
||||
Loc: base + permalink.UserPath(u.ID),
|
||||
LastMod: u.UpdatedAt.UTC(),
|
||||
ChangeFreq: "weekly",
|
||||
Priority: "0.5",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if pages, e3 := h.SitePage.ListSitemap(seoSitemapLimit); e3 == nil {
|
||||
for _, p := range pages {
|
||||
lm := p.UpdatedAt
|
||||
if lm.IsZero() {
|
||||
lm = p.CreatedAt
|
||||
}
|
||||
urls = append(urls, service.SitemapURL{
|
||||
Loc: base + permalink.PagePath(p.Slug),
|
||||
LastMod: lm.UTC(),
|
||||
ChangeFreq: "monthly",
|
||||
Priority: "0.5",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
|
||||
for _, u := range urls {
|
||||
b.WriteString("<url>")
|
||||
b.WriteString("<loc>")
|
||||
b.WriteString(xmlEscape(u.Loc))
|
||||
b.WriteString("</loc>")
|
||||
if !u.LastMod.IsZero() {
|
||||
b.WriteString("<lastmod>")
|
||||
b.WriteString(u.LastMod.Format("2006-01-02"))
|
||||
b.WriteString("</lastmod>")
|
||||
}
|
||||
if u.ChangeFreq != "" {
|
||||
b.WriteString("<changefreq>")
|
||||
b.WriteString(u.ChangeFreq)
|
||||
b.WriteString("</changefreq>")
|
||||
}
|
||||
if u.Priority != "" {
|
||||
b.WriteString("<priority>")
|
||||
b.WriteString(u.Priority)
|
||||
b.WriteString("</priority>")
|
||||
}
|
||||
b.WriteString("</url>")
|
||||
}
|
||||
b.WriteString("</urlset>")
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// ServePublicSPA 公开页入口:
|
||||
// - 普通用户:干净 SPA + <head> meta(无正文预渲染,避免刷新闪屏)
|
||||
// - 搜索/社交爬虫:服务端 HTML(动态渲染)
|
||||
// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301
|
||||
func (h *Handlers) ServePublicSPA(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
brand := h.Settings.SiteBranding()
|
||||
base := h.publicBaseURL(c)
|
||||
siteName := strings.TrimSpace(brand.Name)
|
||||
if siteName == "" {
|
||||
siteName = "姜十三论坛"
|
||||
}
|
||||
defaultImage := service.AbsoluteURL(base, brand.DefaultShareImage())
|
||||
siteKeywords := brand.MetaKeywords()
|
||||
permalink := h.Settings.Permalink()
|
||||
|
||||
// 旧版 /?board=id → 规范板块路径
|
||||
if path == "/" || path == "" {
|
||||
if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 {
|
||||
target := service.QueryBoardHome(uint(boardID), permalink)
|
||||
if q := c.Request.URL.RawQuery; q != "" {
|
||||
// 保留 sort/keyword 等 query,去掉 board
|
||||
vals := c.Request.URL.Query()
|
||||
vals.Del("board")
|
||||
if rest := vals.Encode(); rest != "" {
|
||||
target += "?" + rest
|
||||
}
|
||||
}
|
||||
c.Redirect(http.StatusMovedPermanently, target)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isBot := service.IsSEOCrawler(c.Request.UserAgent())
|
||||
if isBot {
|
||||
c.Header("Vary", "User-Agent")
|
||||
}
|
||||
|
||||
// 板块首页(含可选伪静态后缀)
|
||||
if bm := permalink.MatchBoardPath(path); bm.OK {
|
||||
if bm.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, bm.Canonical+preserveQueryExceptBoard(c))
|
||||
return
|
||||
}
|
||||
board, err := h.Board.GetByID(bm.ID)
|
||||
if err != nil {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
desc := strings.TrimSpace(board.Description)
|
||||
if desc == "" {
|
||||
desc = brand.MetaDescription()
|
||||
}
|
||||
meta := attachSiteSEO(&embed_static.SPAPageMeta{
|
||||
Title: pageTitle(board.Name, siteName),
|
||||
Description: service.TruncateRunes(desc, seoDescMax),
|
||||
Keywords: service.JoinSEOKeywords(board.Name, siteKeywords),
|
||||
Canonical: service.AbsoluteURL(base, bm.Canonical),
|
||||
OGType: "website",
|
||||
OGImage: defaultImage,
|
||||
}, siteName, siteKeywords)
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, meta)
|
||||
return
|
||||
}
|
||||
|
||||
// 帖子详情(含可选伪静态后缀)
|
||||
if pm := permalink.MatchPostPath(path); pm.OK {
|
||||
if pm.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, pm.Canonical)
|
||||
return
|
||||
}
|
||||
post, err := h.Post.FindByID(pm.ID)
|
||||
if err != nil || !service.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
postKeywords := service.JoinSEOKeywords(post.Board.Name, siteKeywords)
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, postKeywords))
|
||||
return
|
||||
}
|
||||
|
||||
// 用户主页
|
||||
if um := permalink.MatchUserPath(path); um.OK {
|
||||
if um.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, um.Canonical)
|
||||
return
|
||||
}
|
||||
user, err := h.User.GetByID(um.ID)
|
||||
if err != nil || user.Banned {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, siteKeywords))
|
||||
return
|
||||
}
|
||||
|
||||
// 自定义单页
|
||||
if pg := permalink.MatchPagePath(path); pg.OK {
|
||||
if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") {
|
||||
c.Redirect(http.StatusMovedPermanently, pg.Canonical)
|
||||
return
|
||||
}
|
||||
page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c))
|
||||
if err != nil {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
desc := service.ExcerptFromHTML(page.Content, seoDescMax)
|
||||
meta := attachSiteSEO(&embed_static.SPAPageMeta{
|
||||
Title: pageTitle(page.Title, siteName),
|
||||
Description: desc,
|
||||
Keywords: service.JoinSEOKeywords(page.Title, siteKeywords),
|
||||
Canonical: service.AbsoluteURL(base, pg.Canonical),
|
||||
OGType: "article",
|
||||
OGImage: defaultImage,
|
||||
}, siteName, siteKeywords)
|
||||
if isBot {
|
||||
body := fmt.Sprintf(`<h1>%s</h1><div>%s</div>`, html.EscapeString(page.Title), page.Content)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, meta)
|
||||
return
|
||||
}
|
||||
|
||||
// 未知路径 → 404
|
||||
if !isKnownPublicPath(path) {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
|
||||
// 其余已知路由:SPA + head meta;首页对爬虫额外返回可读正文
|
||||
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
|
||||
if isBot && (path == "/" || path == "") {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, meta)
|
||||
}
|
||||
|
||||
func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) {
|
||||
if isBot {
|
||||
c.Header("Vary", "User-Agent")
|
||||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, notFoundPageMeta(base, siteName, keywords, path))
|
||||
}
|
||||
|
||||
func notFoundPageMeta(base, siteName, keywords, path string) *embed_static.SPAPageMeta {
|
||||
return attachSiteSEO(&embed_static.SPAPageMeta{
|
||||
Title: pageTitle("页面不存在", siteName),
|
||||
Description: "您访问的页面不存在或已删除",
|
||||
Canonical: service.AbsoluteURL(base, path),
|
||||
OGType: "website",
|
||||
Robots: "noindex,follow",
|
||||
Status: http.StatusNotFound,
|
||||
}, siteName, keywords)
|
||||
}
|
||||
|
||||
// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale
|
||||
func attachSiteSEO(meta *embed_static.SPAPageMeta, siteName, keywords string) *embed_static.SPAPageMeta {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
meta.SiteName = strings.TrimSpace(siteName)
|
||||
if strings.TrimSpace(meta.Keywords) == "" {
|
||||
meta.Keywords = strings.TrimSpace(keywords)
|
||||
}
|
||||
meta.Locale = "zh_CN"
|
||||
return meta
|
||||
}
|
||||
|
||||
func isKnownPublicPath(path string) bool {
|
||||
switch path {
|
||||
case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards":
|
||||
return true
|
||||
}
|
||||
if seoPostEditRe.MatchString(path) {
|
||||
return true
|
||||
}
|
||||
permalink := service.PermalinkConfig{}
|
||||
if permalink.MatchBoardPath(path).OK {
|
||||
return true
|
||||
}
|
||||
if permalink.MatchPagePath(path).OK {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.SiteBranding, base, siteName, defaultImage string) *embed_static.SPAPageMeta {
|
||||
siteTitle := brand.DocumentTitle()
|
||||
homeDesc := service.TruncateRunes(brand.MetaDescription(), seoDescMax)
|
||||
siteKeywords := brand.MetaKeywords()
|
||||
meta := attachSiteSEO(&embed_static.SPAPageMeta{
|
||||
Title: siteTitle,
|
||||
Description: homeDesc,
|
||||
Keywords: siteKeywords,
|
||||
Canonical: service.AbsoluteURL(base, pathWithQuery(c)),
|
||||
OGType: "website",
|
||||
OGImage: defaultImage,
|
||||
}, siteName, siteKeywords)
|
||||
|
||||
if isNoIndexPath(path) {
|
||||
meta.Robots = "noindex,nofollow"
|
||||
meta.Title = pageTitle(pathLabel(path), siteName)
|
||||
return meta
|
||||
}
|
||||
|
||||
if path == "/" || path == "" {
|
||||
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
||||
if boardID > 0 {
|
||||
if board, err := h.Board.GetByID(uint(boardID)); err == nil {
|
||||
desc := strings.TrimSpace(board.Description)
|
||||
if desc == "" {
|
||||
desc = brand.MetaDescription()
|
||||
}
|
||||
meta.Title = pageTitle(board.Name, siteName)
|
||||
meta.Description = service.TruncateRunes(desc, seoDescMax)
|
||||
meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID, h.Settings.Permalink()))
|
||||
meta.Keywords = service.JoinSEOKeywords(board.Name, siteKeywords)
|
||||
return meta
|
||||
}
|
||||
// 无效板块 id:仍显示首页,但可标记 noindex
|
||||
meta.Robots = "noindex,follow"
|
||||
return meta
|
||||
}
|
||||
meta.JSONLD = mustJSON(map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": siteName,
|
||||
"description": meta.Description,
|
||||
"url": service.AbsoluteURL(base, "/"),
|
||||
})
|
||||
}
|
||||
|
||||
if path == "/projects" {
|
||||
meta.Title = pageTitle("项目", siteName)
|
||||
meta.Description = service.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
|
||||
meta.Keywords = service.JoinSEOKeywords("项目", siteKeywords)
|
||||
}
|
||||
|
||||
if path == "/links" {
|
||||
meta.Title = pageTitle("友情链接", siteName)
|
||||
meta.Description = service.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
|
||||
meta.Keywords = service.JoinSEOKeywords("友情链接", siteKeywords)
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model.Post) *embed_static.SPAPageMeta {
|
||||
permalink := h.Settings.Permalink()
|
||||
content := service.RedactGatedPostHTML(post.Content)
|
||||
plain := post.ContentPlain
|
||||
if plain == "" {
|
||||
plain = service.StripHTMLForSearch(content)
|
||||
}
|
||||
desc := service.TruncateRunes(plain, seoDescMax)
|
||||
author := service.DisplayName(&post.User)
|
||||
canonical := service.AbsoluteURL(base, permalink.PostPath(post.ID))
|
||||
ogImage := service.AbsoluteURL(base, service.FirstImageURL(content))
|
||||
if ogImage == "" {
|
||||
ogImage = service.AbsoluteURL(base, post.User.Avatar)
|
||||
}
|
||||
if ogImage == "" {
|
||||
ogImage = defaultImage
|
||||
}
|
||||
|
||||
jsonld := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
"headline": post.Title,
|
||||
"description": desc,
|
||||
"datePublished": post.CreatedAt.UTC().Format(time.RFC3339),
|
||||
"dateModified": post.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
"url": canonical,
|
||||
"mainEntityOfPage": canonical,
|
||||
"author": map[string]any{
|
||||
"@type": "Person",
|
||||
"name": author,
|
||||
"url": service.AbsoluteURL(base, permalink.UserPath(post.UserID)),
|
||||
},
|
||||
"interactionStatistic": map[string]any{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/ViewAction",
|
||||
"userInteractionCount": post.ViewCount,
|
||||
},
|
||||
}
|
||||
if post.Board.Name != "" {
|
||||
jsonld["articleSection"] = post.Board.Name
|
||||
}
|
||||
if ogImage != "" {
|
||||
jsonld["image"] = []string{ogImage}
|
||||
}
|
||||
body := service.TruncateRunes(plain, seoPrerenderMax)
|
||||
if body != "" {
|
||||
jsonld["articleBody"] = body
|
||||
}
|
||||
|
||||
return &embed_static.SPAPageMeta{
|
||||
Title: pageTitle(post.Title, siteName),
|
||||
Description: desc,
|
||||
Canonical: canonical,
|
||||
OGType: "article",
|
||||
OGImage: ogImage,
|
||||
JSONLD: mustJSON(jsonld),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *model.User) *embed_static.SPAPageMeta {
|
||||
permalink := h.Settings.Permalink()
|
||||
name := service.DisplayName(user)
|
||||
desc := strings.TrimSpace(user.Signature)
|
||||
if desc == "" {
|
||||
desc = name + " 的主页"
|
||||
}
|
||||
desc = service.TruncateRunes(desc, seoDescMax)
|
||||
canonical := service.AbsoluteURL(base, permalink.UserPath(user.ID))
|
||||
ogImage := service.AbsoluteURL(base, user.Avatar)
|
||||
if ogImage == "" {
|
||||
ogImage = defaultImage
|
||||
}
|
||||
|
||||
jsonld := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"url": canonical,
|
||||
"mainEntity": map[string]any{
|
||||
"@type": "Person",
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"url": canonical,
|
||||
},
|
||||
}
|
||||
if ogImage != "" {
|
||||
jsonld["mainEntity"].(map[string]any)["image"] = ogImage
|
||||
}
|
||||
|
||||
return &embed_static.SPAPageMeta{
|
||||
Title: pageTitle(name+" 的主页", siteName),
|
||||
Description: desc,
|
||||
Canonical: canonical,
|
||||
OGType: "profile",
|
||||
OGImage: ogImage,
|
||||
JSONLD: mustJSON(jsonld),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) publicBaseURL(c *gin.Context) string {
|
||||
return h.Settings.SitePublicBaseURL(requestOrigin(c))
|
||||
}
|
||||
|
||||
func requestOrigin(c *gin.Context) string {
|
||||
proto := c.GetHeader("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func pathWithQuery(c *gin.Context) string {
|
||||
path := c.Request.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
permalink := service.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
|
||||
if q := c.Request.URL.RawQuery; q != "" {
|
||||
if path == "/" {
|
||||
board := c.Query("board")
|
||||
if board != "" {
|
||||
_ = permalink
|
||||
return service.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
return path + "?" + q
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func preserveQueryExceptBoard(c *gin.Context) string {
|
||||
vals := c.Request.URL.Query()
|
||||
vals.Del("board")
|
||||
if rest := vals.Encode(); rest != "" {
|
||||
return "?" + rest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseUintOrZero(s string) uint64 {
|
||||
n, _ := strconv.ParseUint(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func isNoIndexPath(path string) bool {
|
||||
switch {
|
||||
case path == "/login", path == "/register", path == "/compose",
|
||||
path == "/profile", path == "/favorites":
|
||||
return true
|
||||
case strings.HasPrefix(path, "/admin"):
|
||||
return true
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pathLabel(path string) string {
|
||||
switch {
|
||||
case path == "/login":
|
||||
return "登录"
|
||||
case path == "/register":
|
||||
return "注册"
|
||||
case path == "/compose":
|
||||
return "发帖"
|
||||
case path == "/profile":
|
||||
return "个人中心"
|
||||
case path == "/favorites":
|
||||
return "我的收藏"
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return "编辑帖子"
|
||||
case strings.HasPrefix(path, "/admin"):
|
||||
return "管理后台"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func pageTitle(page, siteName string) string {
|
||||
page = strings.TrimSpace(page)
|
||||
siteName = strings.TrimSpace(siteName)
|
||||
switch {
|
||||
case page == "" && siteName == "":
|
||||
return "姜十三论坛"
|
||||
case page == "":
|
||||
return siteName
|
||||
case siteName == "":
|
||||
return page
|
||||
default:
|
||||
return page + " - " + siteName
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
`&`, "&",
|
||||
`<`, "<",
|
||||
`>`, ">",
|
||||
`"`, """,
|
||||
`'`, "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
158
routers/api/seo_bot.go
Normal file
158
routers/api/seo_bot.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/embed_static"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// 爬虫专用伪静态 HTML(无 SPA;仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏)
|
||||
|
||||
func renderBotHTML(meta *embed_static.SPAPageMeta, bodyInner string) string {
|
||||
if meta == nil {
|
||||
meta = &embed_static.SPAPageMeta{}
|
||||
}
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
if ogType == "" {
|
||||
ogType = "website"
|
||||
}
|
||||
locale := strings.TrimSpace(meta.Locale)
|
||||
if locale == "" {
|
||||
locale = "zh_CN"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<!DOCTYPE html><html lang=\"zh-CN\"><head>")
|
||||
b.WriteString("<meta charset=\"UTF-8\"/>")
|
||||
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>")
|
||||
writeEscapedTag(&b, "title", meta.Title)
|
||||
writeEscapedMeta(&b, "name", "description", meta.Description)
|
||||
writeEscapedMeta(&b, "name", "keywords", meta.Keywords)
|
||||
if meta.Robots != "" {
|
||||
writeEscapedMeta(&b, "name", "robots", meta.Robots)
|
||||
}
|
||||
if meta.Canonical != "" {
|
||||
b.WriteString(`<link rel="canonical" href="` + html.EscapeString(meta.Canonical) + `"/>`)
|
||||
}
|
||||
writeEscapedMeta(&b, "property", "og:type", ogType)
|
||||
writeEscapedMeta(&b, "property", "og:site_name", meta.SiteName)
|
||||
writeEscapedMeta(&b, "property", "og:locale", locale)
|
||||
writeEscapedMeta(&b, "property", "og:title", meta.Title)
|
||||
writeEscapedMeta(&b, "property", "og:description", meta.Description)
|
||||
writeEscapedMeta(&b, "property", "og:url", meta.Canonical)
|
||||
writeEscapedMeta(&b, "property", "og:image", meta.OGImage)
|
||||
card := "summary"
|
||||
if strings.TrimSpace(meta.OGImage) != "" {
|
||||
card = "summary_large_image"
|
||||
}
|
||||
writeEscapedMeta(&b, "name", "twitter:card", card)
|
||||
writeEscapedMeta(&b, "name", "twitter:title", meta.Title)
|
||||
writeEscapedMeta(&b, "name", "twitter:description", meta.Description)
|
||||
writeEscapedMeta(&b, "name", "twitter:image", meta.OGImage)
|
||||
if meta.JSONLD != "" {
|
||||
b.WriteString(`<script type="application/ld+json">`)
|
||||
b.WriteString(meta.JSONLD)
|
||||
b.WriteString(`</script>`)
|
||||
}
|
||||
b.WriteString(`<style>
|
||||
body{font-family:system-ui,sans-serif;line-height:1.6;max-width:800px;margin:24px auto;padding:0 16px;color:#222}
|
||||
a{color:#2d6a4f}img{max-width:100%;height:auto}
|
||||
.meta{color:#666;font-size:14px;margin:8px 0 20px}
|
||||
.nav{margin:32px 0;font-size:14px}
|
||||
</style>`)
|
||||
b.WriteString("</head><body>")
|
||||
b.WriteString(bodyInner)
|
||||
b.WriteString(`<p class="nav"><a href="/">← 返回首页</a></p>`)
|
||||
b.WriteString("</body></html>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeEscapedTag(b *strings.Builder, tag, text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("<" + tag + ">" + html.EscapeString(text) + "</" + tag + ">")
|
||||
}
|
||||
|
||||
func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("<meta " + attr + "=\"" + html.EscapeString(key) + "\" content=\"" + html.EscapeString(content) + "\"/>")
|
||||
}
|
||||
|
||||
func (h *Handlers) botBoardHTML(meta *embed_static.SPAPageMeta, board model.Board) string {
|
||||
desc := strings.TrimSpace(board.Description)
|
||||
if desc == "" {
|
||||
desc = meta.Description
|
||||
}
|
||||
body := fmt.Sprintf(`<h1>%s</h1><p class="meta">%s</p>`,
|
||||
html.EscapeString(board.Name),
|
||||
html.EscapeString(desc),
|
||||
)
|
||||
return renderBotHTML(meta, body)
|
||||
}
|
||||
|
||||
func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.SiteBranding) string {
|
||||
name := strings.TrimSpace(brand.Name)
|
||||
if name == "" {
|
||||
name = "姜十三论坛"
|
||||
}
|
||||
intro := brand.MetaDescription()
|
||||
if intro == "" {
|
||||
intro = brand.Slogan
|
||||
}
|
||||
var body strings.Builder
|
||||
body.WriteString("<h1>" + html.EscapeString(name) + "</h1>")
|
||||
if intro != "" {
|
||||
body.WriteString("<p>" + html.EscapeString(intro) + "</p>")
|
||||
}
|
||||
body.WriteString(`<p><a href="/projects">浏览项目</a></p>`)
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string {
|
||||
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
|
||||
content := service.SanitizePostHTML(service.RedactGatedPostHTML(post.Content))
|
||||
author := service.DisplayName(&post.User)
|
||||
var body strings.Builder
|
||||
body.WriteString("<article>")
|
||||
body.WriteString("<h1>" + html.EscapeString(post.Title) + "</h1>")
|
||||
body.WriteString(`<p class="meta">`)
|
||||
body.WriteString(html.EscapeString(author))
|
||||
body.WriteString(" · ")
|
||||
body.WriteString(html.EscapeString(post.CreatedAt.Local().Format("2006-01-02 15:04")))
|
||||
if post.Board.Name != "" {
|
||||
body.WriteString(" · ")
|
||||
body.WriteString(html.EscapeString(post.Board.Name))
|
||||
}
|
||||
body.WriteString("</p>")
|
||||
body.WriteString(content)
|
||||
body.WriteString("</article>")
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *model.User) string {
|
||||
meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords)
|
||||
name := service.DisplayName(user)
|
||||
sig := strings.TrimSpace(user.Signature)
|
||||
var body strings.Builder
|
||||
body.WriteString("<h1>" + html.EscapeString(name) + " 的主页</h1>")
|
||||
if sig != "" {
|
||||
body.WriteString("<p>" + html.EscapeString(sig) + "</p>")
|
||||
}
|
||||
body.WriteString(fmt.Sprintf(`<p class="meta">加入于 %s</p>`, html.EscapeString(user.CreatedAt.Local().Format(time.DateOnly))))
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func botNotFoundHTML(base, siteName, keywords, path string) string {
|
||||
meta := notFoundPageMeta(base, siteName, keywords, path)
|
||||
body := `<h1>页面不存在</h1><p>您访问的页面不存在或已删除。</p>`
|
||||
return renderBotHTML(meta, body)
|
||||
}
|
||||
198
routers/api/special.go
Normal file
198
routers/api/special.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// APIPages 已发布单页摘要列表
|
||||
func (h *Handlers) APIPages(c *gin.Context) {
|
||||
pages, err := h.SitePage.ListPublished()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if pages == nil {
|
||||
pages = []service.SitePageSummary{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pages": pages})
|
||||
}
|
||||
|
||||
// APIPageDetail 单页详情
|
||||
func (h *Handlers) APIPageDetail(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
allowDraft := h.isAdmin(c)
|
||||
page, err := h.SitePage.GetBySlug(slug, allowDraft)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"page": page})
|
||||
}
|
||||
|
||||
// APIAdminGetPage 管理端单页详情
|
||||
func (h *Handlers) APIAdminGetPage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的单页 ID"})
|
||||
return
|
||||
}
|
||||
page, err := h.SitePage.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "单页不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"page": page})
|
||||
}
|
||||
|
||||
// APIAdminPages 管理端单页列表
|
||||
func (h *Handlers) APIAdminPages(c *gin.Context) {
|
||||
pages, err := h.SitePage.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if pages == nil {
|
||||
pages = []model.SitePage{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pages": pages})
|
||||
}
|
||||
|
||||
// APIAdminCreatePage 创建单页
|
||||
func (h *Handlers) APIAdminCreatePage(c *gin.Context) {
|
||||
var in service.SitePageInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
page, err := h.SitePage.Create(in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "单页已创建", "page": page})
|
||||
}
|
||||
|
||||
// APIAdminUpdatePage 更新单页
|
||||
func (h *Handlers) APIAdminUpdatePage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var in service.SitePageInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
if err := h.SitePage.Update(uint(id), in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "单页已更新"})
|
||||
}
|
||||
|
||||
// APIAdminDeletePage 删除单页
|
||||
func (h *Handlers) APIAdminDeletePage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.SitePage.Delete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "单页已删除"})
|
||||
}
|
||||
|
||||
// APIAdminSetPagePublished 切换单页发布状态
|
||||
func (h *Handlers) APIAdminSetPagePublished(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的单页 ID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
if err := h.SitePage.SetPublished(uint(id), body.Published); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已取消发布"
|
||||
if body.Published {
|
||||
msg = "已发布"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "published": body.Published})
|
||||
}
|
||||
|
||||
// APIPollVote 投票
|
||||
func (h *Handlers) APIPollVote(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var body struct {
|
||||
OptionIDs []uint `json:"option_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
if err := service.VotePoll(uint(id), h.currentUserID(c), body.OptionIDs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
poll, _ := service.GetPollView(uint(id), h.currentUserID(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "投票成功", "poll": poll})
|
||||
}
|
||||
|
||||
// APIPollClose 结束投票
|
||||
func (h *Handlers) APIPollClose(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
if err := service.ClosePoll(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
poll, _ := service.GetPollView(uint(id), h.currentUserID(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "投票已结束", "poll": poll})
|
||||
}
|
||||
|
||||
// APIBountyAward 采纳悬赏
|
||||
func (h *Handlers) APIBountyAward(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
commentID, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64)
|
||||
if commentID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择评论"})
|
||||
return
|
||||
}
|
||||
if err := service.AwardBounty(uint(id), h.currentUserID(c), h.isAdmin(c), uint(commentID)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "悬赏已发放"})
|
||||
}
|
||||
|
||||
// APIBountyRefund 退回悬赏
|
||||
func (h *Handlers) APIBountyRefund(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := service.RefundBounty(uint(id), h.currentUserID(c), h.isAdmin(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "悬赏已退回"})
|
||||
}
|
||||
|
||||
// APILotteryDraw 帖内抽奖开奖
|
||||
func (h *Handlers) APILotteryDraw(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
view, err := service.DrawPostLottery(uint(id), h.currentUserID(c), h.isAdmin(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "开奖完成", "lottery": view})
|
||||
}
|
||||
32
routers/api/thumb.go
Normal file
32
routers/api/thumb.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// ServeImageThumb 帖子图片缩略图(按需生成并缓存)
|
||||
// GET /media/thumb/posts/xxx.webp → 最长边 1280 的 WebP 预览
|
||||
func (h *Handlers) ServeImageThumb(c *gin.Context) {
|
||||
rel := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
uploadsRoot := filepath.Join(h.Cfg.DataDir, "uploads")
|
||||
thumbPath, err := service.EnsureUploadThumb(uploadsRoot, rel)
|
||||
if err != nil {
|
||||
// 生成失败时回退原图,避免正文裂图
|
||||
orig := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
|
||||
if st, e := os.Stat(orig); e == nil && !st.IsDir() {
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.File(orig)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=604800, immutable")
|
||||
c.File(thumbPath)
|
||||
}
|
||||
Reference in New Issue
Block a user