Merge pull request #2202 from Michael-Jetson/main
新增三大功能:兑换码邀请返利、批量修改用户并发数、Markdown页面渲染
This commit is contained in:
commit
d52da45363
@ -74,7 +74,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
|
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
|
||||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||||
redeemCache := repository.NewRedeemCache(redisClient)
|
redeemCache := repository.NewRedeemCache(redisClient)
|
||||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
|
||||||
secretEncryptor, err := repository.NewAESEncryptor(configConfig)
|
secretEncryptor, err := repository.NewAESEncryptor(configConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -175,6 +175,10 @@ func (s *stubAdminService) UpdateUserBalance(ctx context.Context, userID int64,
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) {
|
||||||
|
return len(userIDs), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
|
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
|
||||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -998,17 +998,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, "Custom menu item label is too long (max 50 characters)")
|
response.BadRequest(c, "Custom menu item label is too long (max 50 characters)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(item.URL) == "" {
|
urlTrimmed := strings.TrimSpace(item.URL)
|
||||||
response.BadRequest(c, "Custom menu item URL is required")
|
if strings.HasPrefix(urlTrimmed, "md:") {
|
||||||
return
|
// Markdown page mode: URL = "md:<slug>"
|
||||||
}
|
slug := strings.TrimPrefix(urlTrimmed, "md:")
|
||||||
if len(item.URL) > maxMenuItemURLLen {
|
if slug == "" {
|
||||||
response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
|
response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil {
|
} else {
|
||||||
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
|
if urlTrimmed == "" {
|
||||||
return
|
response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(item.URL) > maxMenuItemURLLen {
|
||||||
|
response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil {
|
||||||
|
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:<slug>")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if item.Visibility != "user" && item.Visibility != "admin" {
|
if item.Visibility != "user" && item.Visibility != "admin" {
|
||||||
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
||||||
|
|||||||
@ -477,3 +477,63 @@ func (h *UserHandler) GetUserRPMStatus(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, status)
|
response.Success(c, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchUpdateConcurrency 批量修改用户并发数
|
||||||
|
// POST /api/v1/admin/users/batch-concurrency
|
||||||
|
type BatchUpdateConcurrencyRequest struct {
|
||||||
|
UserIDs []int64 `json:"user_ids"`
|
||||||
|
All bool `json:"all"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Mode string `json:"mode" binding:"required,oneof=set add"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) {
|
||||||
|
var req BatchUpdateConcurrencyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !req.All && len(req.UserIDs) == 0 {
|
||||||
|
response.BadRequest(c, "user_ids is required unless all=true")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.UserIDs) > 500 {
|
||||||
|
response.BadRequest(c, "user_ids cannot exceed 500")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIDs []int64
|
||||||
|
if req.All {
|
||||||
|
// Fetch all user IDs via pagination
|
||||||
|
page := 1
|
||||||
|
const pageSize = 500
|
||||||
|
for {
|
||||||
|
users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc")
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
userIDs = append(userIDs, u.ID)
|
||||||
|
}
|
||||||
|
if len(users) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userIDs = req.UserIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
response.Success(c, gin.H{"affected": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := h.adminService.BatchUpdateConcurrency(c.Request.Context(), userIDs, req.Concurrency, req.Mode)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{"affected": affected})
|
||||||
|
}
|
||||||
|
|||||||
@ -2798,6 +2798,14 @@ func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int
|
|||||||
panic("unexpected UpdateConcurrency call")
|
panic("unexpected UpdateConcurrency call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
|
||||||
|
panic("unexpected BatchSetConcurrency call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||||
|
panic("unexpected BatchAddConcurrency call")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
|
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
|
||||||
return map[int64]*time.Time{}, nil
|
return map[int64]*time.Time{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ type CustomMenuItem struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
IconSVG string `json:"icon_svg"`
|
IconSVG string `json:"icon_svg"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
PageSlug string `json:"page_slug,omitempty"`
|
||||||
Visibility string `json:"visibility"` // "user" or "admin"
|
Visibility string `json:"visibility"` // "user" or "admin"
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|||||||
215
backend/internal/handler/page_handler.go
Normal file
215
backend/internal/handler/page_handler.go
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
|
||||||
|
|
||||||
|
const maxPageFileSize = 1 << 20 // 1MB
|
||||||
|
|
||||||
|
type PageHandler struct {
|
||||||
|
pagesDir string
|
||||||
|
settingService *service.SettingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPageHandler(dataDir string, settingService *service.SettingService) *PageHandler {
|
||||||
|
pagesDir := filepath.Join(dataDir, "pages")
|
||||||
|
_ = os.MkdirAll(pagesDir, 0755)
|
||||||
|
return &PageHandler{pagesDir: pagesDir, settingService: settingService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageContent serves raw markdown content for a given slug.
|
||||||
|
// GET /api/v1/pages/:slug
|
||||||
|
func (h *PageHandler) GetPageContent(c *gin.Context) {
|
||||||
|
slug := c.Param("slug")
|
||||||
|
if !validSlugPattern.MatchString(slug) || len(slug) > 64 {
|
||||||
|
response.BadRequest(c, "Invalid page slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility check: slug must be configured in custom_menu_items
|
||||||
|
// and the user must have permission based on visibility setting
|
||||||
|
if !h.checkSlugVisibility(c, slug) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(h.pagesDir, slug+".md")
|
||||||
|
cleaned := filepath.Clean(filePath)
|
||||||
|
if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) {
|
||||||
|
response.BadRequest(c, "Invalid page slug")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(cleaned)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.Size() > maxPageFileSize {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "page too large"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read page"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "text/markdown; charset=utf-8", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPages returns available page slugs.
|
||||||
|
// GET /api/v1/pages
|
||||||
|
func (h *PageHandler) ListPages(c *gin.Context) {
|
||||||
|
entries, err := os.ReadDir(h.pagesDir)
|
||||||
|
if err != nil {
|
||||||
|
response.Success(c, []string{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slugs := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasSuffix(name, ".md") {
|
||||||
|
slugs = append(slugs, strings.TrimSuffix(name, ".md"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.Success(c, slugs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServePageImage serves images from data/pages/{slug}/ directory.
|
||||||
|
// GET /api/v1/pages/:slug/images/*filename
|
||||||
|
// No JWT required (browser img tags can't carry tokens), but visibility is checked.
|
||||||
|
func (h *PageHandler) ServePageImage(c *gin.Context) {
|
||||||
|
slug := c.Param("slug")
|
||||||
|
filename := c.Param("filename")
|
||||||
|
filename = strings.TrimPrefix(filename, "/")
|
||||||
|
|
||||||
|
if !validSlugPattern.MatchString(slug) || len(slug) > 64 {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.checkImageSlugVisibility(c, slug) {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imagesDir := filepath.Join(h.pagesDir, slug)
|
||||||
|
filePath := filepath.Join(imagesDir, filename)
|
||||||
|
cleaned := filepath.Clean(filePath)
|
||||||
|
if !strings.HasPrefix(cleaned, filepath.Clean(imagesDir)) {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(cleaned)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.File(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSlugVisibility looks up the slug in custom_menu_items and returns (visibility, found).
|
||||||
|
func (h *PageHandler) findSlugVisibility(c *gin.Context, slug string) (string, bool) {
|
||||||
|
if h.settingService == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := h.settingService.GetCustomMenuItemsRaw(c.Request.Context())
|
||||||
|
if raw == "" || raw == "[]" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
PageSlug string `json:"page_slug"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
itemSlug := item.PageSlug
|
||||||
|
if itemSlug == "" && strings.HasPrefix(item.URL, "md:") {
|
||||||
|
itemSlug = strings.TrimPrefix(item.URL, "md:")
|
||||||
|
}
|
||||||
|
if itemSlug == slug {
|
||||||
|
return item.Visibility, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSlugVisibility verifies the slug is configured in custom_menu_items
|
||||||
|
// and the authenticated user has permission to view it.
|
||||||
|
func (h *PageHandler) checkSlugVisibility(c *gin.Context, slug string) bool {
|
||||||
|
visibility, found := h.findSlugVisibility(c, slug)
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if visibility == "admin" {
|
||||||
|
role, _ := middleware2.GetUserRoleFromContext(c)
|
||||||
|
return role == "admin"
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkImageSlugVisibility checks visibility for image requests (no JWT available).
|
||||||
|
// Only allows user-visible pages; admin-only pages are blocked.
|
||||||
|
func (h *PageHandler) checkImageSlugVisibility(c *gin.Context, slug string) bool {
|
||||||
|
visibility, found := h.findSlugVisibility(c, slug)
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return visibility != "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterPageRoutes registers page routes on a router group.
|
||||||
|
func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string, jwtAuth gin.HandlerFunc, adminAuth gin.HandlerFunc, settingService *service.SettingService) {
|
||||||
|
h := NewPageHandler(dataDir, settingService)
|
||||||
|
|
||||||
|
// Authenticated page content (JWT required + visibility check)
|
||||||
|
pages := v1.Group("/pages")
|
||||||
|
pages.Use(jwtAuth)
|
||||||
|
{
|
||||||
|
pages.GET("/:slug", h.GetPageContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images: no JWT (browser img tags can't carry tokens), visibility check in handler
|
||||||
|
pageImages := v1.Group("/pages")
|
||||||
|
{
|
||||||
|
pageImages.GET("/:slug/images/*filename", h.ServePageImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only: list all available pages
|
||||||
|
adminPages := v1.Group("/pages")
|
||||||
|
adminPages.Use(adminAuth)
|
||||||
|
{
|
||||||
|
adminPages.GET("", h.ListPages)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -87,6 +87,8 @@ func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.Pagina
|
|||||||
func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
|
func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
|
||||||
func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
|
func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
|
||||||
func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
|
func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
|
||||||
|
func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
|
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
|
||||||
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@ -737,6 +737,37 @@ func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if value < 0 {
|
||||||
|
value = 0
|
||||||
|
}
|
||||||
|
res, err := r.sql.ExecContext(ctx,
|
||||||
|
"UPDATE users SET concurrency = $1, updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
|
||||||
|
value, pq.Array(userIDs))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("batch set concurrency: %w", err)
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
return int(affected), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
res, err := r.sql.ExecContext(ctx,
|
||||||
|
"UPDATE users SET concurrency = GREATEST(concurrency + $1, 0), updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
|
||||||
|
delta, pq.Array(userIDs))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("batch add concurrency: %w", err)
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
return int(affected), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||||
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
|
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1125,7 +1125,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil, nil, cfg)
|
subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil, nil, cfg)
|
||||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||||
|
|
||||||
redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil)
|
redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil, nil)
|
||||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||||
|
|
||||||
settingRepo := newStubSettingRepo()
|
settingRepo := newStubSettingRepo()
|
||||||
@ -1296,6 +1296,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
|
|||||||
return errors.New("not implemented")
|
return errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||||
return false, errors.New("not implemented")
|
return false, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -198,6 +198,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
|
|||||||
panic("unexpected UpdateConcurrency call")
|
panic("unexpected UpdateConcurrency call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||||
panic("unexpected ExistsByEmail call")
|
panic("unexpected ExistsByEmail call")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -112,4 +112,6 @@ func registerRoutes(
|
|||||||
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
||||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
|
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
|
||||||
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService)
|
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService)
|
||||||
|
|
||||||
|
handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -245,6 +245,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
|
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
|
||||||
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
|
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
|
||||||
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
|
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
|
||||||
|
users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency)
|
||||||
|
|
||||||
// User attribute values
|
// User attribute values
|
||||||
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)
|
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type AdminService interface {
|
|||||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
|
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
|
||||||
DeleteUser(ctx context.Context, id int64) error
|
DeleteUser(ctx context.Context, id int64) error
|
||||||
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
|
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
|
||||||
|
BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error)
|
||||||
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
|
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
|
||||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
||||||
GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error)
|
GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error)
|
||||||
@ -817,6 +818,39 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) {
|
||||||
|
cleaned := make([]int64, 0, len(userIDs))
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if uid > 0 {
|
||||||
|
cleaned = append(cleaned, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var affected int
|
||||||
|
var err error
|
||||||
|
switch mode {
|
||||||
|
case "set":
|
||||||
|
affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value)
|
||||||
|
case "add":
|
||||||
|
affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value)
|
||||||
|
default:
|
||||||
|
return 0, errors.New("invalid mode: must be 'set' or 'add'")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.authCacheInvalidator != nil {
|
||||||
|
for _, uid := range cleaned {
|
||||||
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
|
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -68,6 +68,9 @@ func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float
|
|||||||
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error {
|
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error {
|
||||||
panic("unexpected")
|
panic("unexpected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
|
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
|
||||||
panic("unexpected")
|
panic("unexpected")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,6 +131,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i
|
|||||||
panic("unexpected UpdateConcurrency call")
|
panic("unexpected UpdateConcurrency call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||||
if s.existsErr != nil {
|
if s.existsErr != nil {
|
||||||
return false, s.existsErr
|
return false, s.existsErr
|
||||||
|
|||||||
@ -113,6 +113,9 @@ func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64)
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
||||||
|
|
||||||
func (s *emailSyncRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
|
func (s *emailSyncRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
|
||||||
|
|||||||
@ -820,6 +820,9 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (
|
|||||||
return ok, nil
|
return ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -282,7 +282,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
|
|||||||
case redeemActionRedeem:
|
case redeemActionRedeem:
|
||||||
// Code exists but unused — skip creation, proceed to redeem
|
// Code exists but unused — skip creation, proceed to redeem
|
||||||
}
|
}
|
||||||
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
|
if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil {
|
||||||
return fmt.Errorf("redeem balance: %w", err)
|
return fmt.Errorf("redeem balance: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
|
if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
|
||||||
|
|||||||
@ -208,6 +208,7 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
|
|||||||
nil,
|
nil,
|
||||||
client,
|
client,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
registry := payment.NewRegistry()
|
registry := payment.NewRegistry()
|
||||||
provider := &paymentOrderLifecycleQueryProvider{
|
provider := &paymentOrderLifecycleQueryProvider{
|
||||||
@ -308,6 +309,7 @@ func TestVerifyOrderByOutTradeNoRetriesZeroAmountPaidQueryOnce(t *testing.T) {
|
|||||||
nil,
|
nil,
|
||||||
client,
|
client,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
registry := payment.NewRegistry()
|
registry := payment.NewRegistry()
|
||||||
provider := &paymentOrderLifecycleQueryProvider{
|
provider := &paymentOrderLifecycleQueryProvider{
|
||||||
@ -398,6 +400,7 @@ func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) {
|
|||||||
nil,
|
nil,
|
||||||
client,
|
client,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
registry := payment.NewRegistry()
|
registry := payment.NewRegistry()
|
||||||
provider := &paymentOrderLifecycleQueryProvider{
|
provider := &paymentOrderLifecycleQueryProvider{
|
||||||
@ -496,6 +499,7 @@ func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsFor
|
|||||||
nil,
|
nil,
|
||||||
client,
|
client,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
registry := payment.NewRegistry()
|
registry := payment.NewRegistry()
|
||||||
provider := &paymentOrderLifecycleQueryProvider{
|
provider := &paymentOrderLifecycleQueryProvider{
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,6 +29,15 @@ const (
|
|||||||
redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁
|
redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ctxKeySkipRedeemAffiliate struct{}
|
||||||
|
|
||||||
|
// ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level
|
||||||
|
// affiliate rebate. Used by payment fulfillment which handles rebate separately
|
||||||
|
// via applyAffiliateRebateForOrder (with audit-log deduplication).
|
||||||
|
func ContextSkipRedeemAffiliate(ctx context.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKeySkipRedeemAffiliate{}, true)
|
||||||
|
}
|
||||||
|
|
||||||
// RedeemCache defines cache operations for redeem service
|
// RedeemCache defines cache operations for redeem service
|
||||||
type RedeemCache interface {
|
type RedeemCache interface {
|
||||||
GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error)
|
GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error)
|
||||||
@ -80,6 +90,7 @@ type RedeemService struct {
|
|||||||
billingCacheService *BillingCacheService
|
billingCacheService *BillingCacheService
|
||||||
entClient *dbent.Client
|
entClient *dbent.Client
|
||||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||||
|
affiliateService *AffiliateService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRedeemService 创建兑换码服务实例
|
// NewRedeemService 创建兑换码服务实例
|
||||||
@ -91,6 +102,7 @@ func NewRedeemService(
|
|||||||
billingCacheService *BillingCacheService,
|
billingCacheService *BillingCacheService,
|
||||||
entClient *dbent.Client,
|
entClient *dbent.Client,
|
||||||
authCacheInvalidator APIKeyAuthCacheInvalidator,
|
authCacheInvalidator APIKeyAuthCacheInvalidator,
|
||||||
|
affiliateService *AffiliateService,
|
||||||
) *RedeemService {
|
) *RedeemService {
|
||||||
return &RedeemService{
|
return &RedeemService{
|
||||||
redeemRepo: redeemRepo,
|
redeemRepo: redeemRepo,
|
||||||
@ -100,6 +112,7 @@ func NewRedeemService(
|
|||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
entClient: entClient,
|
entClient: entClient,
|
||||||
authCacheInvalidator: authCacheInvalidator,
|
authCacheInvalidator: authCacheInvalidator,
|
||||||
|
affiliateService: affiliateService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -369,6 +382,11 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
|
|||||||
// 事务提交成功后失效缓存
|
// 事务提交成功后失效缓存
|
||||||
s.invalidateRedeemCaches(ctx, userID, redeemCode)
|
s.invalidateRedeemCaches(ctx, userID, redeemCode)
|
||||||
|
|
||||||
|
// 余额类正数兑换码触发邀请返利(best-effort,失败不影响兑换结果)
|
||||||
|
if redeemCode.Type == RedeemTypeBalance && redeemCode.Value > 0 {
|
||||||
|
s.tryAccrueAffiliateRebateForRedeem(ctx, userID, redeemCode.Value)
|
||||||
|
}
|
||||||
|
|
||||||
// 重新获取更新后的兑换码
|
// 重新获取更新后的兑换码
|
||||||
redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID)
|
redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -418,6 +436,26 @@ func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RedeemService) tryAccrueAffiliateRebateForRedeem(ctx context.Context, userID int64, amount float64) {
|
||||||
|
if ctx.Value(ctxKeySkipRedeemAffiliate{}) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.affiliateService == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.affiliateService.IsEnabled(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rebate, err := s.affiliateService.AccrueInviteRebate(ctx, userID, amount)
|
||||||
|
if err != nil {
|
||||||
|
logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate failed for user %d amount %.2f: %v", userID, amount, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rebate > 0 {
|
||||||
|
logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate accrued %.8f for inviter of user %d", rebate, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID 根据ID获取兑换码
|
// GetByID 根据ID获取兑换码
|
||||||
func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
|
func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
|
||||||
code, err := s.redeemRepo.GetByID(ctx, id)
|
code, err := s.redeemRepo.GetByID(ctx, id)
|
||||||
|
|||||||
@ -1542,6 +1542,15 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
|||||||
return value == "true"
|
return value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting.
|
||||||
|
func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string {
|
||||||
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems)
|
||||||
|
if err != nil {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
|
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
|
||||||
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
|
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
|
||||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
|
||||||
|
|||||||
@ -96,6 +96,8 @@ type UserRepository interface {
|
|||||||
UpdateBalance(ctx context.Context, id int64, amount float64) error
|
UpdateBalance(ctx context.Context, id int64, amount float64) error
|
||||||
DeductBalance(ctx context.Context, id int64, amount float64) error
|
DeductBalance(ctx context.Context, id int64, amount float64) error
|
||||||
UpdateConcurrency(ctx context.Context, id int64, amount int) error
|
UpdateConcurrency(ctx context.Context, id int64, amount int) error
|
||||||
|
BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error)
|
||||||
|
BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
|
||||||
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
||||||
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
|
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
|
||||||
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
|
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
|
||||||
|
|||||||
@ -199,6 +199,9 @@ func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { re
|
|||||||
func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
|
func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||||
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
||||||
func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
|
func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
|
||||||
out := make([]UserAuthIdentityRecord, len(m.identities))
|
out := make([]UserAuthIdentityRecord, len(m.identities))
|
||||||
|
|||||||
@ -168,6 +168,7 @@ export interface CustomMenuItem {
|
|||||||
label: string
|
label: string
|
||||||
icon_svg: string
|
icon_svg: string
|
||||||
url: string
|
url: string
|
||||||
|
page_slug?: string
|
||||||
visibility: 'user' | 'admin'
|
visibility: 'user' | 'admin'
|
||||||
sort_order: number
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown mode with TOC -->
|
||||||
|
<div v-else-if="isMarkdownMode" class="flex h-full overflow-hidden">
|
||||||
|
<!-- TOC Sidebar -->
|
||||||
|
<aside
|
||||||
|
v-show="tocVisible"
|
||||||
|
class="toc-sidebar"
|
||||||
|
>
|
||||||
|
<div class="toc-header">
|
||||||
|
<span class="toc-title">目录</span>
|
||||||
|
<button class="toc-close-btn" @click="tocVisible = false">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="toc-nav">
|
||||||
|
<a
|
||||||
|
v-for="item in tocItems"
|
||||||
|
:key="item.id"
|
||||||
|
:href="'#' + item.id"
|
||||||
|
class="toc-item"
|
||||||
|
:class="[
|
||||||
|
`toc-level-${item.level}`,
|
||||||
|
{ 'toc-active': activeHeadingId === item.id }
|
||||||
|
]"
|
||||||
|
@click.prevent="scrollToHeading(item.id)"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- TOC Toggle Button (when collapsed) -->
|
||||||
|
<button
|
||||||
|
v-show="!tocVisible && tocItems.length > 0"
|
||||||
|
class="toc-toggle-btn"
|
||||||
|
@click="tocVisible = true"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||||
|
<span class="ml-1 text-xs">目录</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div
|
||||||
|
ref="markdownContainer"
|
||||||
|
class="markdown-page-content flex-1 h-full overflow-auto p-6 md:p-10"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
@scroll="onContentScroll"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL not configured -->
|
||||||
<div v-else-if="!isValidUrl" class="flex h-full items-center justify-center p-10 text-center">
|
<div v-else-if="!isValidUrl" class="flex h-full items-center justify-center p-10 text-center">
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<div
|
<div
|
||||||
@ -43,6 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Iframe embed mode -->
|
||||||
<div v-else class="custom-embed-shell">
|
<div v-else class="custom-embed-shell">
|
||||||
<a
|
<a
|
||||||
:href="embeddedUrl"
|
:href="embeddedUrl"
|
||||||
@ -65,7 +116,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores'
|
import { useAppStore } from '@/stores'
|
||||||
@ -74,6 +125,14 @@ import { useAdminSettingsStore } from '@/stores/adminSettings'
|
|||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
|
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
interface TocItem {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -83,25 +142,38 @@ const adminSettingsStore = useAdminSettingsStore()
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const pageTheme = ref<'light' | 'dark'>('light')
|
const pageTheme = ref<'light' | 'dark'>('light')
|
||||||
|
const renderedHtml = ref('')
|
||||||
|
const markdownContainer = ref<HTMLElement | null>(null)
|
||||||
|
const tocItems = ref<TocItem[]>([])
|
||||||
|
const tocVisible = ref(typeof window !== 'undefined' ? window.innerWidth > 768 : true)
|
||||||
|
const activeHeadingId = ref('')
|
||||||
let themeObserver: MutationObserver | null = null
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
const menuItemId = computed(() => route.params.id as string)
|
const menuItemId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
const menuItem = computed(() => {
|
const menuItem = computed(() => {
|
||||||
const id = menuItemId.value
|
const id = menuItemId.value
|
||||||
// Try public settings first (contains user-visible items)
|
|
||||||
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||||
const found = publicItems.find((item) => item.id === id) ?? null
|
const found = publicItems.find((item) => item.id === id) ?? null
|
||||||
if (found) return found
|
if (found) return found
|
||||||
// For admin users, also check admin settings (contains admin-only items)
|
|
||||||
if (authStore.isAdmin) {
|
if (authStore.isAdmin) {
|
||||||
return adminSettingsStore.customMenuItems.find((item) => item.id === id) ?? null
|
return adminSettingsStore.customMenuItems.find((item) => item.id === id) ?? null
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const markdownSlug = computed(() => {
|
||||||
|
const item = menuItem.value
|
||||||
|
if (!item) return ''
|
||||||
|
if (item.page_slug) return item.page_slug
|
||||||
|
if (item.url?.startsWith('md:')) return item.url.slice(3)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMarkdownMode = computed(() => !!markdownSlug.value)
|
||||||
|
|
||||||
const embeddedUrl = computed(() => {
|
const embeddedUrl = computed(() => {
|
||||||
if (!menuItem.value) return ''
|
if (!menuItem.value || isMarkdownMode.value) return ''
|
||||||
return buildEmbeddedUrl(
|
return buildEmbeddedUrl(
|
||||||
menuItem.value.url,
|
menuItem.value.url,
|
||||||
authStore.user?.id,
|
authStore.user?.id,
|
||||||
@ -112,10 +184,141 @@ const embeddedUrl = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isValidUrl = computed(() => {
|
const isValidUrl = computed(() => {
|
||||||
|
if (isMarkdownMode.value) return false
|
||||||
const url = embeddedUrl.value
|
const url = embeddedUrl.value
|
||||||
return url.startsWith('http://') || url.startsWith('https://')
|
return url.startsWith('http://') || url.startsWith('https://')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function generateHeadingId(text: string, index: number): string {
|
||||||
|
const base = text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w一-鿿]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
return base ? `${base}-${index}` : `heading-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndRenderMarkdown(slug: string) {
|
||||||
|
loading.value = true
|
||||||
|
tocItems.value = []
|
||||||
|
activeHeadingId.value = ''
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/v1/pages/${encodeURIComponent(slug)}`, {
|
||||||
|
headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {},
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
renderedHtml.value = '<p class="text-red-500">Page not found</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let raw = await resp.text()
|
||||||
|
|
||||||
|
raw = raw.replace(
|
||||||
|
/!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g,
|
||||||
|
(_, alt, src) => ``
|
||||||
|
)
|
||||||
|
|
||||||
|
const html = marked.parse(raw) as string
|
||||||
|
const sanitized = DOMPurify.sanitize(html, {
|
||||||
|
ADD_TAGS: ['iframe'],
|
||||||
|
ADD_ATTR: ['allowfullscreen', 'frameborder', 'src'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inject IDs into headings and build TOC
|
||||||
|
const toc: TocItem[] = []
|
||||||
|
let headingIndex = 0
|
||||||
|
const withIds = sanitized.replace(
|
||||||
|
/<(h[1-4])[^>]*>(.*?)<\/h[1-4]>/gi,
|
||||||
|
(_, tag: string, content: string) => {
|
||||||
|
const level = parseInt(tag[1])
|
||||||
|
const text = content.replace(/<[^>]+>/g, '').trim()
|
||||||
|
const id = generateHeadingId(text, headingIndex++)
|
||||||
|
toc.push({ id, text, level })
|
||||||
|
return `<${tag} id="${id}">${content}</${tag}>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
renderedHtml.value = withIds
|
||||||
|
tocItems.value = toc
|
||||||
|
} catch {
|
||||||
|
renderedHtml.value = '<p class="text-red-500">Failed to load page</p>'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
injectCopyButtons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHeading(id: string) {
|
||||||
|
const container = markdownContainer.value
|
||||||
|
if (!container) return
|
||||||
|
const el = container.querySelector(`#${CSS.escape(id)}`)
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
activeHeadingId.value = id
|
||||||
|
if (window.innerWidth <= 640) {
|
||||||
|
tocVisible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollRafId = 0
|
||||||
|
function onContentScroll() {
|
||||||
|
if (scrollRafId) return
|
||||||
|
scrollRafId = requestAnimationFrame(() => {
|
||||||
|
scrollRafId = 0
|
||||||
|
const container = markdownContainer.value
|
||||||
|
if (!container || tocItems.value.length === 0) return
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
let current = ''
|
||||||
|
|
||||||
|
for (const item of tocItems.value) {
|
||||||
|
const el = container.querySelector(`#${CSS.escape(item.id)}`) as HTMLElement | null
|
||||||
|
if (el) {
|
||||||
|
const elRect = el.getBoundingClientRect()
|
||||||
|
if (elRect.top - containerRect.top <= 100) {
|
||||||
|
current = item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeHeadingId.value = current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectCopyButtons() {
|
||||||
|
const container = markdownContainer.value
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
container.querySelectorAll('pre').forEach((pre) => {
|
||||||
|
if (pre.querySelector('.copy-btn')) return
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.className = 'copy-btn'
|
||||||
|
btn.textContent = '复制'
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? ''
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
btn.textContent = '已复制 ✓'
|
||||||
|
setTimeout(() => { btn.textContent = '复制' }, 2000)
|
||||||
|
} catch {
|
||||||
|
btn.textContent = '失败'
|
||||||
|
setTimeout(() => { btn.textContent = '复制' }, 2000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pre.style.position = 'relative'
|
||||||
|
pre.appendChild(btn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(markdownSlug, (slug) => {
|
||||||
|
if (slug) {
|
||||||
|
fetchAndRenderMarkdown(slug)
|
||||||
|
} else {
|
||||||
|
renderedHtml.value = ''
|
||||||
|
tocItems.value = []
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
pageTheme.value = detectTheme()
|
pageTheme.value = detectTheme()
|
||||||
|
|
||||||
@ -152,6 +355,64 @@ onUnmounted(() => {
|
|||||||
height: calc(100vh - 64px - 4rem);
|
height: calc(100vh - 64px - 4rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toc-sidebar {
|
||||||
|
@apply flex flex-col h-full border-r border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-800;
|
||||||
|
width: min(240px, 30%);
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.toc-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: 70%;
|
||||||
|
max-width: 240px;
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-header {
|
||||||
|
@apply flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-dark-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-title {
|
||||||
|
@apply text-sm font-semibold text-gray-700 dark:text-dark-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-close-btn {
|
||||||
|
@apply p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-dark-200 hover:bg-gray-200 dark:hover:bg-dark-600 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-nav {
|
||||||
|
@apply flex-1 overflow-y-auto py-2 px-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item {
|
||||||
|
@apply block px-2 py-1.5 text-sm rounded transition-colors truncate;
|
||||||
|
@apply text-gray-600 dark:text-dark-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-dark-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-item.toc-active {
|
||||||
|
@apply text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-level-1 { padding-left: 8px; }
|
||||||
|
.toc-level-2 { padding-left: 20px; }
|
||||||
|
.toc-level-3 { padding-left: 32px; }
|
||||||
|
.toc-level-4 { padding-left: 44px; }
|
||||||
|
|
||||||
|
.toc-toggle-btn {
|
||||||
|
@apply absolute left-2 top-2 z-10 flex items-center px-2 py-1.5 rounded-md text-sm;
|
||||||
|
@apply bg-white dark:bg-dark-700 border border-gray-200 dark:border-dark-500;
|
||||||
|
@apply text-gray-600 dark:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-600;
|
||||||
|
@apply shadow-sm transition-colors cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-embed-shell {
|
.custom-embed-shell {
|
||||||
@apply relative;
|
@apply relative;
|
||||||
@apply h-full w-full overflow-hidden rounded-2xl;
|
@apply h-full w-full overflow-hidden rounded-2xl;
|
||||||
@ -175,3 +436,46 @@ onUnmounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.markdown-page-content {
|
||||||
|
line-height: 1.7;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.markdown-page-content h1 { @apply text-3xl font-bold mt-8 mb-4 pb-2 border-b border-gray-200 dark:border-dark-600; }
|
||||||
|
.markdown-page-content h2 { @apply text-2xl font-bold mt-6 mb-3; }
|
||||||
|
.markdown-page-content h3 { @apply text-xl font-semibold mt-5 mb-2; }
|
||||||
|
.markdown-page-content h4 { @apply text-lg font-semibold mt-4 mb-2; }
|
||||||
|
.markdown-page-content p { @apply mb-4; }
|
||||||
|
.markdown-page-content ul { @apply list-disc pl-6 mb-4; }
|
||||||
|
.markdown-page-content ol { @apply list-decimal pl-6 mb-4; }
|
||||||
|
.markdown-page-content li { @apply mb-1; }
|
||||||
|
.markdown-page-content a { @apply text-primary-500 hover:text-primary-600 underline; }
|
||||||
|
.markdown-page-content blockquote { @apply border-l-4 border-gray-300 dark:border-dark-500 pl-4 italic text-gray-600 dark:text-dark-300 my-4; }
|
||||||
|
.markdown-page-content img { @apply max-w-full h-auto rounded-lg my-4; }
|
||||||
|
.markdown-page-content table { @apply w-full border-collapse my-4; }
|
||||||
|
.markdown-page-content th { @apply border border-gray-300 dark:border-dark-500 px-3 py-2 bg-gray-50 dark:bg-dark-700 font-semibold text-left; }
|
||||||
|
.markdown-page-content td { @apply border border-gray-300 dark:border-dark-500 px-3 py-2; }
|
||||||
|
.markdown-page-content code { @apply bg-gray-100 dark:bg-dark-700 px-1.5 py-0.5 rounded text-sm font-mono; }
|
||||||
|
.markdown-page-content pre { @apply bg-gray-900 dark:bg-dark-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-4 relative; }
|
||||||
|
.markdown-page-content pre code { @apply bg-transparent p-0 text-inherit; }
|
||||||
|
.markdown-page-content hr { @apply my-6 border-gray-200 dark:border-dark-600; }
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.copy-btn:hover { background: rgba(255, 255, 255, 0.25); }
|
||||||
|
pre:hover .copy-btn { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user