feat: 更新SproutGate前后端代码
This commit is contained in:
@@ -23,30 +23,6 @@ func (h *Handler) ListUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
|
||||
}
|
||||
|
||||
func (h *Handler) GetPublicUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
|
||||
if user.Banned {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.PublicProfile()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -151,7 +127,11 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if req.Banned != nil {
|
||||
wasBanned := user.Banned
|
||||
user.Banned = *req.Banned
|
||||
if user.Banned && !wasBanned {
|
||||
user.TokenEpoch++
|
||||
}
|
||||
if !user.Banned {
|
||||
user.BanReason = ""
|
||||
user.BannedAt = ""
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *Handler) Login(c *gin.Context) {
|
||||
writeBanJSON(c, user.BanReason)
|
||||
return
|
||||
}
|
||||
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
|
||||
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, user.TokenEpoch, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
@@ -82,11 +82,10 @@ func (h *Handler) Verify(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
h := gin.H{"valid": false, "error": "account is banned"}
|
||||
if r := strings.TrimSpace(user.BanReason); r != "" {
|
||||
h["banReason"] = r
|
||||
}
|
||||
c.JSON(http.StatusOK, h)
|
||||
writeBanJSON(c, user.BanReason)
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if cid, cname, ok := authClientFromHeaders(c); ok {
|
||||
@@ -118,6 +117,9 @@ func (h *Handler) Me(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if cid, cname, ok := authClientFromHeaders(c); ok {
|
||||
if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {
|
||||
user = rec
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"sproutgate-backend/internal/email"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
@@ -19,13 +20,17 @@ func (h *Handler) Register(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Account = storage.NormalizeSelfServiceAccount(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
inviteTrim := strings.TrimSpace(req.InviteCode)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.ValidateSelfServiceAccount(req.Account); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
requireInv := h.store.RegistrationRequireInvite()
|
||||
if requireInv && inviteTrim == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"})
|
||||
@@ -89,12 +94,16 @@ func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Account = storage.NormalizeSelfServiceAccount(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.ValidateSelfServiceAccount(req.Account); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pending, found, err := h.store.GetPending(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
|
||||
@@ -114,13 +123,17 @@ func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
sproutCoins := 0
|
||||
if strings.TrimSpace(pending.InviteCode) != "" {
|
||||
sproutCoins = h.store.RegistrationInviteRegisterRewardCoins()
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: pending.Account,
|
||||
PasswordHash: pending.PasswordHash,
|
||||
Username: pending.Username,
|
||||
Email: pending.Email,
|
||||
Level: 0,
|
||||
SproutCoins: 0,
|
||||
SproutCoins: sproutCoins,
|
||||
SecondaryEmails: []string{},
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
|
||||
@@ -35,6 +35,9 @@ func (h *Handler) CheckIn(c *gin.Context) {
|
||||
if abortIfUserBanned(c, userPre) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, userPre) {
|
||||
return
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
nowAt := models.CurrentActivityTime()
|
||||
user, reward, alreadyCheckedIn, err := h.store.CheckIn(claims.Account, today, nowAt)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -54,13 +55,22 @@ func verifyCode(code string, hash string) bool {
|
||||
}
|
||||
|
||||
func writeBanJSON(c *gin.Context, reason string) {
|
||||
h := gin.H{"error": "account is banned"}
|
||||
h := gin.H{"error": "account is banned", "valid": false}
|
||||
if r := strings.TrimSpace(reason); r != "" {
|
||||
h["banReason"] = r
|
||||
}
|
||||
c.JSON(http.StatusForbidden, h)
|
||||
}
|
||||
|
||||
// abortIfTokenEpochStale 若 JWT 内 epoch 与用户当前 token_epoch 不一致,则拒绝(例如管理员已封禁并递增 epoch)。
|
||||
func abortIfTokenEpochStale(c *gin.Context, claims *auth.Claims, u models.UserRecord) bool {
|
||||
if claims.TokenEpoch == u.TokenEpoch {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "token revoked"})
|
||||
return true
|
||||
}
|
||||
|
||||
func abortIfUserBanned(c *gin.Context, u models.UserRecord) bool {
|
||||
if !u.Banned {
|
||||
return false
|
||||
|
||||
@@ -38,6 +38,9 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
154
sproutgate-backend/internal/handlers/public_profile.go
Normal file
154
sproutgate-backend/internal/handlers/public_profile.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
// ListPublicUsers 公开用户目录(未封禁用户,默认按注册时间从早到晚)。
|
||||
func (h *Handler) ListPublicUsers(c *gin.Context) {
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
out := make([]models.PublicUserListEntry, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.Banned {
|
||||
continue
|
||||
}
|
||||
out = append(out, u.PublicListEntry())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.TrimSpace(out[i].CreatedAt) < strings.TrimSpace(out[j].CreatedAt)
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"total": len(out), "users": out})
|
||||
}
|
||||
|
||||
func (h *Handler) GetPublicUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
|
||||
if user.Banned {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
likeCount, err := h.store.CountProfileLikes(user.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load likes"})
|
||||
return
|
||||
}
|
||||
payload := gin.H{
|
||||
"user": user.PublicProfile(),
|
||||
"profileLikeCount": likeCount,
|
||||
}
|
||||
if token := bearerToken(c.GetHeader("Authorization")); token != "" {
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err == nil {
|
||||
viewer, vFound, vErr := h.store.GetUser(claims.Account)
|
||||
if vErr == nil && vFound && !viewer.Banned && claims.TokenEpoch == viewer.TokenEpoch {
|
||||
if strings.EqualFold(viewer.Account, user.Account) {
|
||||
payload["viewerIsOwner"] = true
|
||||
} else {
|
||||
has, _ := h.store.ViewerHasLikedProfileToday(viewer.Account, user.Account)
|
||||
payload["viewerHasLikedToday"] = has
|
||||
rem, _ := h.store.ProfileLikeRemainingToday(viewer.Account)
|
||||
payload["viewerLikesRemainingToday"] = rem
|
||||
payload["profileLikeDailyMax"] = storage.MaxProfileLikesPerDay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
}
|
||||
|
||||
func (h *Handler) PostPublicProfileLike(c *gin.Context) {
|
||||
likedParam := strings.TrimSpace(c.Param("account"))
|
||||
if likedParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
likerUser, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if abortIfUserBanned(c, likerUser) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, likerUser) {
|
||||
return
|
||||
}
|
||||
|
||||
newCount, err := h.store.AddProfileLike(likerUser.Account, likedParam)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrCannotLikeOwnProfile):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, storage.ErrProfileLikeTarget):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, storage.ErrAlreadyLikedToday):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, storage.ErrDailyLikeLimitReached):
|
||||
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
"viewerLikesRemainingToday": rem,
|
||||
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
|
||||
})
|
||||
return
|
||||
case errors.Is(err, storage.ErrProfileLikeLiker):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"profileLikeCount": newCount,
|
||||
"viewerHasLikedToday": true,
|
||||
"viewerLikesRemainingToday": rem,
|
||||
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
// GetPublicRegistrationPolicy 公开:是否必须邀请码(不含具体邀请码)。
|
||||
// GetPublicRegistrationPolicy 公开:是否必须邀请码、自助注册账号规则说明(不含具体邀请码)。
|
||||
func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": h.store.RegistrationRequireInvite(),
|
||||
"requireInviteCode": h.store.RegistrationRequireInvite(),
|
||||
"inviteRegisterRewardCoins": h.store.RegistrationInviteRegisterRewardCoins(),
|
||||
"selfServiceAccountMin": storage.MinSelfServiceAccountLen,
|
||||
"selfServiceAccountMax": storage.MaxSelfServiceAccountLen,
|
||||
"selfServiceAccountHint": "lowercase letters and digits only",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
func (h *Handler) GetAdminRegistration(c *gin.Context) {
|
||||
cfg := h.store.GetRegistrationConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": cfg.RequireInviteCode,
|
||||
"invites": cfg.Invites,
|
||||
"requireInviteCode": cfg.RequireInviteCode,
|
||||
"forbiddenAccounts": cfg.ForbiddenAccounts,
|
||||
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
|
||||
"invites": cfg.Invites,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,11 +23,16 @@ func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.store.SetRegistrationRequireInvite(req.RequireInviteCode); err != nil {
|
||||
if err := h.store.UpdateRegistrationPolicy(req.RequireInviteCode, req.ForbiddenAccounts, req.InviteRegisterRewardCoins); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save registration policy"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"requireInviteCode": req.RequireInviteCode})
|
||||
cfg := h.store.GetRegistrationConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": cfg.RequireInviteCode,
|
||||
"forbiddenAccounts": cfg.ForbiddenAccounts,
|
||||
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) PostAdminInvite(c *gin.Context) {
|
||||
|
||||
@@ -64,7 +64,9 @@ type updateCheckInConfigRequest struct {
|
||||
}
|
||||
|
||||
type updateRegistrationPolicyRequest struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
ForbiddenAccounts string `json:"forbiddenAccounts"`
|
||||
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins"` // nil 表示不修改此项
|
||||
}
|
||||
|
||||
type createInviteRequest struct {
|
||||
|
||||
@@ -46,6 +46,9 @@ func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(user.Email) == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
|
||||
return
|
||||
@@ -137,6 +140,9 @@ func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if abortIfTokenEpochStale(c, claims, user) {
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
|
||||
Reference in New Issue
Block a user