Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ func Run() {
// 歌词校验
validateSvc := service.NewValidateService(cfg.Worker.BaseURL)

// 公开用户主页(本地扩展资料表)
userProfileRepo := repository.NewUserProfileRepo(db)
userProfileSvc := service.NewUserProfileService(
userProfileRepo, subRepo, fileSvc, casdoorClient, cfg.Casdoor.Organization,
)
userSearchSvc := service.NewUserSearchService(casdoorClient, userProfileRepo, subRepo)

// 4.1 启动无歌词服务
appCtx, appCancel := context.WithCancel(context.Background())
defer appCancel()
Expand Down Expand Up @@ -241,7 +248,7 @@ func Run() {
// 5. 初始化 handler
syncH := handler.NewSyncHandler(syncSvc)
lyricsH := handler.NewLyricsHandler(lyricsSvc, notFoundSvc)
searchH := handler.NewSearchHandler(searchSvc)
searchH := handler.NewSearchHandler(searchSvc, userSearchSvc)
batchH := handler.NewBatchHandler(batchSvc)
statsH := handler.NewStatsHandler(statsSvc)
indexH := handler.NewIndexHandler(indexSvc)
Expand All @@ -265,6 +272,8 @@ func Run() {
adminH := handler.NewAdminHandler(reviewerRepo, reviewerCache, notifSvc)
notifH := handler.NewNotificationHandler(notifSvc)
userActivityH := handler.NewUserActivityHandler(userActivitySvc)
userProfileH := handler.NewUserProfileHandler(userProfileSvc)
userSearchH := handler.NewUserSearchHandler(userSearchSvc)
wsH := handler.NewWSHandler(hub, viewerSvc, reviewerCache, cfg.Casdoor.JWTSecret)

// 6. 启动 HTTP
Expand Down Expand Up @@ -294,6 +303,8 @@ func Run() {
Admin: adminH,
Notification: notifH,
UserActivity: userActivityH,
UserProfile: userProfileH,
UserSearch: userSearchH,

ReviewerCache: reviewerCache,
AdminCache: adminCache,
Expand Down
49 changes: 47 additions & 2 deletions backend/internal/handler/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import (
// SearchHandler 搜索 handler
type SearchHandler struct {
svc *service.SearchService
// users 仅用于统计「用户」Tab 的命中数
users *service.UserSearchService
}

func NewSearchHandler(svc *service.SearchService) *SearchHandler {
return &SearchHandler{svc: svc}
func NewSearchHandler(svc *service.SearchService, users *service.UserSearchService) *SearchHandler {
return &SearchHandler{svc: svc, users: users}
}

// Search GET /api/v1/search?q=&field=&limit=&offset=&all=
Expand Down Expand Up @@ -60,3 +62,46 @@ func (h *SearchHandler) Search(c *gin.Context) {

pkg.OK(c, result)
}

// Counts GET /api/v1/search/counts?q=
// 返回搜索结果页各 Tab 的命中数。
func (h *SearchHandler) Counts(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
ctx, cancel := context.WithTimeout(c.Request.Context(), defaultTimeout)
defer cancel()

totals := map[string]int64{
"all": 0, "song": 0, "artist": 0, "album": 0,
"lyric": 0, "id": 0, "author": 0, "user": 0,
}
userMore := false

if q != "" {
t, err := h.svc.CountByFields(ctx, q)
if err != nil {
if errors.Is(err, service.ErrUpstreamUnavailable) {
writeUpstreamErr(c, err, "搜索服务暂不可用")
return
}
logrus.WithError(err).Error("search counts failed")
pkg.InternalError(c, "统计搜索数量失败")
return
}
for k, v := range t {
totals[k] = v
}

// 用户数来自 Casdoor
if n, more, err := h.users.Count(ctx, q, 20); err == nil {
totals["user"] = int64(n)
userMore = more
} else {
logrus.WithError(err).Warn("count users for search tabs failed")
}
}

pkg.OK(c, gin.H{
"totals": totals,
"userMore": userMore,
})
}
6 changes: 5 additions & 1 deletion backend/internal/handler/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,11 @@ func (h *UploadHandler) UploadAudio(c *gin.Context) {

// publicServePrefixes 是允许匿名访问的 MinIO 前缀白名单,
// 防止通过该接口读取 bucket 内其他对象
var publicServePrefixes = []string{service.MusicPrefix, service.CoverPrefix}
var publicServePrefixes = []string{
service.MusicPrefix,
service.CoverPrefix,
service.ProfileBannerPrefix,
}

// isPublicServeKey 校验 key 是否落在公开前缀内且不含路径穿越
func isPublicServeKey(key string) bool {
Expand Down
50 changes: 43 additions & 7 deletions backend/internal/handler/user_activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handler
import (
"context"
"strconv"
"strings"

"github.com/amll-dev/amll-hub/backend/internal/pkg"
"github.com/amll-dev/amll-hub/backend/internal/service"
Expand All @@ -27,13 +28,9 @@ func (h *UserActivityHandler) Get(c *gin.Context) {
return
}

year := 0
if y := c.Query("year"); y != "" {
var err error
if year, err = strconv.Atoi(y); err != nil || year < 2000 || year > 9999 {
pkg.BadRequest(c, "无效的 year")
return
}
year, ok := parseActivityYear(c)
if !ok {
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), defaultTimeout)
Expand All @@ -46,3 +43,42 @@ func (h *UserActivityHandler) Get(c *gin.Context) {
}
pkg.OK(c, data)
}

// GetPublic GET /api/v1/users/:name/activity?year=2026
// 访客视角:只统计已通过投稿,不含审核行为。
func (h *UserActivityHandler) GetPublic(c *gin.Context) {
name := strings.TrimSpace(c.Param("name"))
if !validProfileName(name) {
pkg.BadRequest(c, "无效的用户名")
return
}

year, ok := parseActivityYear(c)
if !ok {
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), defaultTimeout)
defer cancel()

data, err := h.svc.GetPublicActivity(ctx, name, year)
if err != nil {
pkg.InternalError(c, "查询活动统计失败")
return
}
pkg.OK(c, data)
}

// parseActivityYear 解析 year 查询参数;非法时已写响应并返回 false
func parseActivityYear(c *gin.Context) (int, bool) {
raw := c.Query("year")
if raw == "" {
return 0, true
}
year, err := strconv.Atoi(raw)
if err != nil || year < 2000 || year > 9999 {
pkg.BadRequest(c, "无效的 year")
return 0, false
}
return year, true
}
204 changes: 204 additions & 0 deletions backend/internal/handler/user_profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package handler

import (
"context"
"errors"
"net/http"
"strconv"
"strings"

"github.com/amll-dev/amll-hub/backend/internal/middleware"
"github.com/amll-dev/amll-hub/backend/internal/pkg"
"github.com/amll-dev/amll-hub/backend/internal/service"
"github.com/gin-gonic/gin"
logrus "github.com/sirupsen/logrus"
)

// 公开主页投稿列表的默认每页条数与上限
const (
publicSubmissionPageSize = 20
publicSubmissionMaxPageSize = 50
)

// 允许的列表筛选值(白名单,其它值一律忽略)
var (
publicSubmissionSorts = map[string]bool{"newest": true, "oldest": true, "updated": true}
publicSubmissionLanguages = map[string]bool{"zh": true, "en": true, "ja": true, "ko": true, "others": true}
publicSubmissionStatuses = map[string]bool{
"all": true, "processing": true,
"draft": true, "pending": true, "reviewing": true, "revised": true,
"need_revision": true, "missing_audio": true,
"approved": true, "rejected": true, "closed": true,
}
)

// UserProfileHandler 公开用户主页
type UserProfileHandler struct {
svc *service.UserProfileService
}

// NewUserProfileHandler 创建 handler
func NewUserProfileHandler(svc *service.UserProfileService) *UserProfileHandler {
return &UserProfileHandler{svc: svc}
}

// Get GET /api/v1/users/:name
func (h *UserProfileHandler) Get(c *gin.Context) {
name, ok := profileNameParam(c)
if !ok {
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), defaultTimeout)
defer cancel()

profile, err := h.svc.GetPublicProfile(ctx, name, middleware.GetUserName(c))
if err != nil {
writeUserProfileErr(c, err)
return
}
pkg.OK(c, profile)
}

// ListSubmissions GET /api/v1/users/:name/submissions
func (h *UserProfileHandler) ListSubmissions(c *gin.Context) {
name, ok := profileNameParam(c)
if !ok {
return
}

page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(publicSubmissionPageSize)))
if limit < 1 {
limit = publicSubmissionPageSize
}
if limit > publicSubmissionMaxPageSize {
limit = publicSubmissionMaxPageSize
}

q := service.PublicSubmissionQuery{Page: page, Limit: limit}
if v := c.Query("status"); publicSubmissionStatuses[v] {
q.Status = v
}
if v := c.Query("language"); publicSubmissionLanguages[v] {
q.Language = v
}
if v := c.Query("sort"); publicSubmissionSorts[v] {
q.Sort = v
}

ctx, cancel := context.WithTimeout(c.Request.Context(), defaultTimeout)
defer cancel()

// 只有本人(登录且用户名一致)才能看到非 approved 的投稿
isSelf := middleware.GetUserName(c) == name

list, err := h.svc.ListPublicSubmissions(ctx, name, isSelf, q)
if err != nil {
writeUserProfileErr(c, err)
return
}
pkg.OK(c, list)
}

// UpdateMe PUT /api/v1/users/me/profile
func (h *UserProfileHandler) UpdateMe(c *gin.Context) {
username := middleware.GetUserName(c)
if username == "" {
pkg.Unauthorized(c)
return
}

var req service.UpdateMyProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
pkg.BadRequest(c, "请求体格式错误")
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), defaultTimeout)
defer cancel()

fields, err := h.svc.UpdateMyProfile(ctx, username, req)
if err != nil {
writeUserProfileErr(c, err)
return
}
pkg.OK(c, fields)
}

// UploadBanner POST /api/v1/users/me/banner
func (h *UserProfileHandler) UploadBanner(c *gin.Context) {
username := middleware.GetUserName(c)
if username == "" {
pkg.Unauthorized(c)
return
}

fh, err := c.FormFile("file")
if err != nil {
pkg.BadRequest(c, "file 字段必填")
return
}
f, err := fh.Open()
if err != nil {
logrus.WithError(err).Warn("open profile banner failed")
pkg.BadRequest(c, "读取文件失败")
return
}
defer f.Close()

content, err := readLimited(f, service.MaxProfileBannerSize)
if err != nil {
pkg.BadRequest(c, err.Error())
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), longTimeout)
defer cancel()

key, err := h.svc.UploadBanner(ctx, username, content)
if err != nil {
writeUserProfileErr(c, err)
return
}
pkg.OK(c, gin.H{"bannerKey": key})
}

// profileNameParam 读取并校验 :name 路径参数;非法时已写响应并返回 false
func profileNameParam(c *gin.Context) (string, bool) {
name := strings.TrimSpace(c.Param("name"))
if !validProfileName(name) {
pkg.BadRequest(c, "无效的用户名")
return "", false
}
return name, true
}

// validProfileName 用户名长度与非法字符校验(Casdoor username 是字符串主键)
func validProfileName(name string) bool {
if name == "" || len(name) > 100 {
return false
}
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
return false
}
return true
}

// writeUserProfileErr 统一映射主页相关错误
func writeUserProfileErr(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrUserNotFound):
pkg.Fail(c, http.StatusNotFound, http.StatusNotFound, "用户不存在")
case errors.Is(err, service.ErrInvalidInput):
pkg.BadRequest(c, "参数不合法")
case errors.Is(err, service.ErrUpstreamUnavailable):
pkg.Fail(c, http.StatusBadGateway, http.StatusBadGateway, "用户服务暂不可用")
default:
logrus.WithError(err).Error("user profile operation failed")
pkg.InternalError(c, "服务异常,请稍后重试")
}
}
Loading
Loading