diff --git a/backend/cmd/server/server.go b/backend/cmd/server/server.go index a034e96..3459992 100644 --- a/backend/cmd/server/server.go +++ b/backend/cmd/server/server.go @@ -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() @@ -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) @@ -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 @@ -294,6 +303,8 @@ func Run() { Admin: adminH, Notification: notifH, UserActivity: userActivityH, + UserProfile: userProfileH, + UserSearch: userSearchH, ReviewerCache: reviewerCache, AdminCache: adminCache, diff --git a/backend/internal/handler/search.go b/backend/internal/handler/search.go index 691acfe..57c88b0 100644 --- a/backend/internal/handler/search.go +++ b/backend/internal/handler/search.go @@ -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= @@ -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, + }) +} diff --git a/backend/internal/handler/upload.go b/backend/internal/handler/upload.go index bdaab58..6b7a6cc 100644 --- a/backend/internal/handler/upload.go +++ b/backend/internal/handler/upload.go @@ -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 { diff --git a/backend/internal/handler/user_activity.go b/backend/internal/handler/user_activity.go index e67475a..937209f 100644 --- a/backend/internal/handler/user_activity.go +++ b/backend/internal/handler/user_activity.go @@ -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" @@ -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) @@ -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 +} diff --git a/backend/internal/handler/user_profile.go b/backend/internal/handler/user_profile.go new file mode 100644 index 0000000..47c1d92 --- /dev/null +++ b/backend/internal/handler/user_profile.go @@ -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, "服务异常,请稍后重试") + } +} diff --git a/backend/internal/handler/user_search.go b/backend/internal/handler/user_search.go new file mode 100644 index 0000000..c2e95d0 --- /dev/null +++ b/backend/internal/handler/user_search.go @@ -0,0 +1,36 @@ +package handler + +import ( + "context" + "strings" + + "github.com/amll-dev/amll-hub/backend/internal/pkg" + "github.com/amll-dev/amll-hub/backend/internal/service" + "github.com/gin-gonic/gin" +) + +// UserSearchHandler 站点用户搜索 +type UserSearchHandler struct { + svc *service.UserSearchService +} + +// NewUserSearchHandler 创建 handler +func NewUserSearchHandler(svc *service.UserSearchService) *UserSearchHandler { + return &UserSearchHandler{svc: svc} +} + +// Search GET /api/v1/users/search?q=&limit= +func (h *UserSearchHandler) Search(c *gin.Context) { + q := strings.TrimSpace(c.Query("q")) + limit := pkg.Clamp(pkg.ParseInt(c.Query("limit"), 20), 1, 50) + + ctx, cancel := context.WithTimeout(c.Request.Context(), defaultTimeout) + defer cancel() + + result, err := h.svc.Search(ctx, q, limit) + if err != nil { + writeUpstreamErr(c, err, "用户服务暂不可用") + return + } + pkg.OK(c, result) +} diff --git a/backend/internal/infrastructure/casdoor.go b/backend/internal/infrastructure/casdoor.go index edd4f09..8a39465 100644 --- a/backend/internal/infrastructure/casdoor.go +++ b/backend/internal/infrastructure/casdoor.go @@ -13,6 +13,7 @@ import ( "net/http/cookiejar" "net/url" "strings" + "sync" "time" "github.com/sirupsen/logrus" @@ -25,8 +26,16 @@ import ( type CasdoorClient struct { cfg config.CasdoorConfig http *http.Client + + // 用户搜索回退路径的全量列表缓存,避免「查无结果」时每次都重拉一页用户 + userListMu sync.Mutex + userListCache []CasdoorUser + userListAt time.Time } +// userListCacheTTL 回退用户列表的缓存时长 +const userListCacheTTL = 5 * time.Minute + // NewCasdoorClient 创建 Casdoor 客户端 func NewCasdoorClient(cfg config.CasdoorConfig) *CasdoorClient { return &CasdoorClient{ @@ -230,6 +239,10 @@ func (c *CasdoorClient) doRequest(req *http.Request) (*casdoorResponse, error) { return nil, fmt.Errorf("read casdoor response: %w", err) } + if resp.StatusCode == http.StatusNotFound { + return nil, ErrCasdoorUserNotFound + } + var cr casdoorResponse if err := json.Unmarshal(raw, &cr); err != nil { return nil, fmt.Errorf("decode casdoor response: %w, body: %s", err, string(raw)) @@ -237,6 +250,26 @@ func (c *CasdoorClient) doRequest(req *http.Request) (*casdoorResponse, error) { return &cr, nil } +// ErrCasdoorUserNotFound Casdoor 侧明确表示用户不存在 +var ErrCasdoorUserNotFound = errors.New("casdoor user not found") + +// isCasdoorNotFound 判断 Casdoor 的错误消息是否在表达「用户不存在」 +func isCasdoorNotFound(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrCasdoorUserNotFound) { + return true + } + msg := strings.ToLower(err.Error()) + for _, kw := range []string{"does not exist", "not found", "not exist", "不存在"} { + if strings.Contains(msg, kw) { + return true + } + } + return false +} + // checkStatus 检查 Casdoor 响应状态,失败返回错误 func checkStatus(cr *casdoorResponse) error { if cr.Status != "ok" { @@ -499,8 +532,128 @@ func phoneMatches(stored, target string) bool { // queryUsersByField 按字段查询用户列表 func (c *CasdoorClient) queryUsersByField(ctx context.Context, field, value string) ([]CasdoorUser, error) { - path := fmt.Sprintf("/api/get-users?owner=%s&field=%s&value=%s&pageSize=100&p=1", - url.QueryEscape(c.cfg.Organization), url.QueryEscape(field), url.QueryEscape(value)) + return c.queryUsersByFieldLimit(ctx, field, value, 100) +} + +// queryUsersByFieldLimit 按字段查询用户列表,可指定返回条数 +func (c *CasdoorClient) queryUsersByFieldLimit(ctx context.Context, field, value string, limit int) ([]CasdoorUser, error) { + if limit <= 0 { + limit = 100 + } + path := fmt.Sprintf("/api/get-users?owner=%s&field=%s&value=%s&pageSize=%d&p=1", + url.QueryEscape(c.cfg.Organization), url.QueryEscape(field), url.QueryEscape(value), limit) + cr, err := c.doWithBasicAuth(ctx, "GET", path, nil, "") + if err != nil { + return nil, err + } + if err := checkStatus(cr); err != nil { + return nil, err + } + var users []CasdoorUser + if err := json.Unmarshal(cr.Data, &users); err != nil { + return nil, fmt.Errorf("decode users: %w", err) + } + return users, nil +} + +// localUserScanLimit 回退方案单次扫描的用户上限,避免拉全量账号 +const localUserScanLimit = 1000 + +// SearchUsers 按用户名 / 昵称搜索用户,结果按 Name 去重 +func (c *CasdoorClient) SearchUsers(ctx context.Context, keyword string, limit int) ([]CasdoorUser, error) { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return nil, nil + } + if limit <= 0 { + limit = 20 + } + + seen := make(map[string]struct{}, limit) + out := make([]CasdoorUser, 0, limit) + for _, field := range []string{"name", "displayName"} { + users, err := c.queryUsersByFieldLimit(ctx, field, keyword, limit) + if err != nil { + // 某个字段不被接受时继续尝试下一个,两个字段都失败才上报 + if isCasdoorNotFound(err) { + continue + } + return nil, err + } + for i := range users { + u := users[i] + if u.Name == "" { + continue + } + if _, ok := seen[u.Name]; ok { + continue + } + seen[u.Name] = struct{}{} + out = append(out, u) + if len(out) >= limit { + return out, nil + } + } + } + if len(out) > 0 { + return out, nil + } + return c.searchUsersLocally(ctx, keyword, limit) +} + +// searchUsersLocally 拉一页用户后本地做包含匹配 +func (c *CasdoorClient) searchUsersLocally(ctx context.Context, keyword string, limit int) ([]CasdoorUser, error) { + users, err := c.cachedUsers(ctx) + if err != nil { + return nil, err + } + kw := strings.ToLower(keyword) + out := make([]CasdoorUser, 0, limit) + for i := range users { + u := users[i] + if u.Name == "" { + continue + } + if strings.Contains(strings.ToLower(u.Name), kw) || + strings.Contains(strings.ToLower(u.DisplayName), kw) { + out = append(out, u) + if len(out) >= limit { + break + } + } + } + return out, nil +} + +// cachedUsers 带 TTL 的用户列表缓存,回退路径专用 +func (c *CasdoorClient) cachedUsers(ctx context.Context) ([]CasdoorUser, error) { + c.userListMu.Lock() + if c.userListCache != nil && time.Since(c.userListAt) < userListCacheTTL { + cached := c.userListCache + c.userListMu.Unlock() + return cached, nil + } + c.userListMu.Unlock() + + users, err := c.listUsers(ctx, localUserScanLimit) + if err != nil { + return nil, err + } + + c.userListMu.Lock() + c.userListCache = users + c.userListAt = time.Now() + c.userListMu.Unlock() + return users, nil +} + +// listUsers 拉取组织下的一页用户 +func (c *CasdoorClient) listUsers(ctx context.Context, pageSize int) ([]CasdoorUser, error) { + if pageSize <= 0 { + pageSize = 100 + } + path := fmt.Sprintf("/api/get-users?owner=%s&pageSize=%d&p=1", + url.QueryEscape(c.cfg.Organization), pageSize) cr, err := c.doWithBasicAuth(ctx, "GET", path, nil, "") if err != nil { return nil, err @@ -640,15 +793,24 @@ func (c *CasdoorClient) SendVerificationCode(ctx context.Context, checkType, che func (c *CasdoorClient) GetUser(ctx context.Context, owner, name string) (*CasdoorUser, error) { cr, err := c.doWithBasicAuth(ctx, "GET", fmt.Sprintf("/api/get-user?id=%s/%s", url.PathEscape(owner), url.PathEscape(name)), nil, "") if err != nil { + if isCasdoorNotFound(err) { + return nil, ErrCasdoorUserNotFound + } return nil, err } if err := checkStatus(cr); err != nil { + if isCasdoorNotFound(err) { + return nil, ErrCasdoorUserNotFound + } return nil, err } var user CasdoorUser if err := json.Unmarshal(cr.Data, &user); err != nil { return nil, fmt.Errorf("decode user: %w", err) } + if user.Name == "" { + return nil, ErrCasdoorUserNotFound + } return &user, nil } diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index ba645dd..ab0f4ee 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -36,6 +36,18 @@ func Auth(jwtSecret string) gin.HandlerFunc { } } +// OptionalAuth 公开接口的可选认证 +func OptionalAuth(jwtSecret string) gin.HandlerFunc { + return func(c *gin.Context) { + if token := bearerToken(c.GetHeader("Authorization")); token != "" { + if claims, err := pkg.ParseJWT(token, jwtSecret); err == nil { + applyClaims(c, claims) + } + } + c.Next() + } +} + // bearerToken 从 Authorization 头解析出 Bearer token(格式非法返回空串) func bearerToken(authHeader string) string { if authHeader == "" { @@ -48,6 +60,14 @@ func bearerToken(authHeader string) string { return parts[1] } +// applyClaims 把解析好的 claims 写入 context,Auth / OptionalAuth 共用 +func applyClaims(c *gin.Context, claims *pkg.Claims) { + c.Set(UserIDKey, claims.Sub) + c.Set(UserNameKey, claims.Name) + c.Set(UserDisplayNameKey, claims.DisplayName) + c.Set(UserAvatarKey, claims.Avatar) +} + // setClaims 解析 JWT 并写入 context;失败时写响应并返回 false func setClaims(c *gin.Context, token, jwtSecret string) bool { claims, err := pkg.ParseJWT(token, jwtSecret) @@ -55,10 +75,7 @@ func setClaims(c *gin.Context, token, jwtSecret string) bool { pkg.Fail(c, http.StatusUnauthorized, http.StatusUnauthorized, "invalid or expired token") return false } - c.Set(UserIDKey, claims.Sub) - c.Set(UserNameKey, claims.Name) - c.Set(UserDisplayNameKey, claims.DisplayName) - c.Set(UserAvatarKey, claims.Avatar) + applyClaims(c, claims) return true } diff --git a/backend/internal/model/user_profile.go b/backend/internal/model/user_profile.go new file mode 100644 index 0000000..da20679 --- /dev/null +++ b/backend/internal/model/user_profile.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// 性别取值(user_profiles.gender,与迁移里的 CHECK 约束一致) +const ( + GenderMale = "male" + GenderFemale = "female" + GenderOther = "other" + GenderUnknown = "unknown" +) + +// UserProfile 用户公开主页的本地扩展资料。 +// +// 用户身份主键是 Casdoor 的 username(JWT claims.name),这里不做外键, +// 只存 Casdoor 里没有的展示字段:签名 / 背景图 / 地区 / 生日 / 性别。 +type UserProfile struct { + Username string `gorm:"column:username;primaryKey;type:varchar(100)" json:"username"` + Bio string `gorm:"column:bio;type:varchar(200);not null;default:''" json:"bio"` + BannerKey string `gorm:"column:banner_key;type:varchar(500);not null;default:''" json:"bannerKey"` + Birthday *time.Time `gorm:"column:birthday;type:date" json:"birthday"` + Gender string `gorm:"column:gender;type:varchar(16);not null;default:'unknown'" json:"gender"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +// TableName 表名 +func (UserProfile) TableName() string { return "user_profiles" } diff --git a/backend/internal/repository/submission_repo.go b/backend/internal/repository/submission_repo.go index a5b8a94..144400a 100644 --- a/backend/internal/repository/submission_repo.go +++ b/backend/internal/repository/submission_repo.go @@ -211,8 +211,10 @@ type ListQuery struct { Status string // all / pending / processing / rejected / approved / need_revision / missing_audio Language string // others / ja / zh / en / ko Search string - Page int - Limit int + // Sort 排序:newest(默认)/ oldest / updated + Sort string + Page int + Limit int // Submitter 仅 creator 模式有效,限定投稿人 Submitter string } @@ -291,8 +293,16 @@ func (r *SubmissionRepo) List(ctx context.Context, q ListQuery) (*ListResult, er } var items []model.Submission + // Sort 只做白名单映射,绝不把外部传入的字符串拼进 Order + order := "created_at DESC" + switch q.Sort { + case "oldest": + order = "created_at ASC" + case "updated": + order = "updated_at DESC" + } if err := db. - Order("created_at DESC"). + Order(order). Offset((q.Page - 1) * q.Limit). Limit(q.Limit). Find(&items).Error; err != nil { @@ -302,6 +312,31 @@ func (r *SubmissionRepo) List(ctx context.Context, q ListQuery) (*ListResult, er return &ListResult{Total: total, Items: items}, nil } +// CountApprovedBySubmitters 批量统计一批用户的已通过投稿数,返回 submitter → count +func (r *SubmissionRepo) CountApprovedBySubmitters(ctx context.Context, submitters []string) (map[string]int64, error) { + out := make(map[string]int64, len(submitters)) + if len(submitters) == 0 { + return out, nil + } + type row struct { + Submitter string + Cnt int64 + } + var rows []row + if err := r.db.WithContext(ctx).Model(&model.Submission{}). + Select("submitter, COUNT(*) AS cnt"). + Where("submitter IN ?", submitters). + Where("status = ?", model.StatusApproved). + Group("submitter"). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, item := range rows { + out[item.Submitter] = item.Cnt + } + return out, nil +} + // Stats 各状态计数 type Stats struct { Pending int64 `json:"pending"` diff --git a/backend/internal/repository/user_profile_repo.go b/backend/internal/repository/user_profile_repo.go new file mode 100644 index 0000000..c68cd4c --- /dev/null +++ b/backend/internal/repository/user_profile_repo.go @@ -0,0 +1,67 @@ +package repository + +import ( + "context" + + "github.com/amll-dev/amll-hub/backend/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UserProfileRepo 用户主页扩展资料数据访问层 +type UserProfileRepo struct { + db *gorm.DB +} + +func NewUserProfileRepo(db *gorm.DB) *UserProfileRepo { + return &UserProfileRepo{db: db} +} + +// Get 查询用户主页资料,不存在时返回 gorm.ErrRecordNotFound +func (r *UserProfileRepo) Get(ctx context.Context, username string) (*model.UserProfile, error) { + var p model.UserProfile + if err := r.db.WithContext(ctx).First(&p, "username = ?", username).Error; err != nil { + return nil, err + } + return &p, nil +} + +// Upsert 写入展示字段。刻意不碰 banner_key——背景图只能走上传接口, +// 避免普通表单保存时把已上传的图覆盖成空串。 +func (r *UserProfileRepo) Upsert(ctx context.Context, p *model.UserProfile) error { + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "username"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "bio", "birthday", "gender", + }), + }).Create(p).Error +} + +// ListByUsernames 批量查询主页资料,返回 username → profile 的映射 +func (r *UserProfileRepo) ListByUsernames(ctx context.Context, usernames []string) (map[string]*model.UserProfile, error) { + out := make(map[string]*model.UserProfile, len(usernames)) + if len(usernames) == 0 { + return out, nil + } + var rows []model.UserProfile + if err := r.db.WithContext(ctx). + Where("username IN ?", usernames). + Find(&rows).Error; err != nil { + return nil, err + } + for i := range rows { + out[rows[i].Username] = &rows[i] + } + return out, nil +} + +// UpdateBanner 单独更新背景图 key。 +// +// 用原生 upsert 而非 GORM Create:新建行时只提供 username+banner_key, +// 其余字段交给表默认值,避免零值把 gender 写成空串(违反 CHECK 约束)。 +func (r *UserProfileRepo) UpdateBanner(ctx context.Context, username, bannerKey string) error { + return r.db.WithContext(ctx).Exec(` + INSERT INTO user_profiles (username, banner_key) VALUES (?, ?) + ON CONFLICT (username) DO UPDATE SET banner_key = EXCLUDED.banner_key + `, username, bannerKey).Error +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6b8c6b0..e3c8f4d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -40,6 +40,8 @@ type RouterDeps struct { Admin *handler.AdminHandler Notification *handler.NotificationHandler UserActivity *handler.UserActivityHandler + UserProfile *handler.UserProfileHandler + UserSearch *handler.UserSearchHandler ReviewerCache *middleware.ReviewerCache AdminCache *middleware.AdminCache @@ -73,6 +75,8 @@ func New(deps RouterDeps) *gin.Engine { adminH := deps.Admin notifH := deps.Notification userActivityH := deps.UserActivity + userProfileH := deps.UserProfile + userSearchH := deps.UserSearch reviewerCache := deps.ReviewerCache adminCache := deps.AdminCache jwtSecret := deps.JWTSecret @@ -131,6 +135,8 @@ func New(deps RouterDeps) *gin.Engine { { // 搜索 api.GET("/search", searchH.Search) + // 搜索页 Tab 的命中数(歌曲各字段 + 用户) + api.GET("/search/counts", searchH.Counts) // 同步触发/状态 api.POST("/sync", syncH.Trigger) @@ -273,6 +279,12 @@ func New(deps RouterDeps) *gin.Engine { // 个人中心活动统计 sub.GET("/users/me/activity", userActivityH.Get) + // 主页扩展资料 + // 必须注册在公开组 /users/:name 之前:gin 的路由树里 + // 参数段晚于静态段插入,否则启动时 panic。 + sub.PUT("/users/me/profile", userProfileH.UpdateMe) + sub.POST("/users/me/banner", userProfileH.UploadBanner) + // GitHub 绑定解绑 sub.DELETE("/user/github/binding", authGithubH.Unbind) @@ -349,6 +361,18 @@ func New(deps RouterDeps) *gin.Engine { api.GET("/lyrics/view/:filename", lyricsH.ViewLyric) // 歌词解析 api.POST("/lyrics/parse", lyricsH.ParseLyric) + + // 公开用户主页(匿名可访问;带合法 token 时按本人视角返回更多数据) + // 这组路由必须在上面 /users/me/* 之后注册,理由见那里的注释 + users := api.Group("/users") + users.Use(middleware.OptionalAuth(jwtSecret)) + { + // 静态段必须先于 :name,否则 gin 构建路由树时 panic + users.GET("/search", userSearchH.Search) + users.GET("/:name", userProfileH.Get) + users.GET("/:name/submissions", userProfileH.ListSubmissions) + users.GET("/:name/activity", userActivityH.GetPublic) + } } return r diff --git a/backend/internal/router/router_build_test.go b/backend/internal/router/router_build_test.go new file mode 100644 index 0000000..fbb4d62 --- /dev/null +++ b/backend/internal/router/router_build_test.go @@ -0,0 +1,32 @@ +package router + +import "testing" + +// 路由树构建回归:gin 要求静态段先于参数段注册, +// 顺序写错会在启动期 panic,这里把风险挡在测试里。 +func TestNew_RegistersUserProfileRoutes(t *testing.T) { + r := New(RouterDeps{}) + + want := map[string]bool{ + "GET /api/v1/users/:name": false, + "GET /api/v1/users/:name/submissions": false, + "GET /api/v1/users/:name/activity": false, + "GET /api/v1/users/me/activity": false, + "PUT /api/v1/users/me/profile": false, + "POST /api/v1/users/me/banner": false, + "GET /api/v1/users/search": false, + "GET /api/v1/search": false, + "GET /api/v1/search/counts": false, + } + for _, rt := range r.Routes() { + key := rt.Method + " " + rt.Path + if _, ok := want[key]; ok { + want[key] = true + } + } + for key, ok := range want { + if !ok { + t.Errorf("route not registered: %s", key) + } + } +} diff --git a/backend/internal/service/file_service.go b/backend/internal/service/file_service.go index 62bd8c7..a533b57 100644 --- a/backend/internal/service/file_service.go +++ b/backend/internal/service/file_service.go @@ -21,8 +21,13 @@ const ( MusicPrefix = "music/" CoverPrefix = "tg-image/" RevisionLyricsPrefix = "tg-lyrics-review/" + // ProfileBannerPrefix 用户主页背景图,需在 handler 的匿名可读白名单里 + ProfileBannerPrefix = "profile-banner/" ) +// MaxProfileBannerSize 主页背景图大小上限(前端压缩后远小于此值) +const MaxProfileBannerSize = 5 << 20 + // FileService 投稿模块文件服务 type FileService struct { cfg *config.Config @@ -127,6 +132,47 @@ func (s *FileService) CoverURL(coverKey string) string { return coverKey } +// UploadProfileBanner 上传用户主页背景图,返回对象 key +func (s *FileService) UploadProfileBanner(ctx context.Context, username string, content []byte, ext string) (string, error) { + if len(content) == 0 { + return "", ErrInvalidInput + } + if len(content) > MaxProfileBannerSize { + return "", fmt.Errorf("背景图大小超过 %d 字节", MaxProfileBannerSize) + } + ext = strings.ToLower(ext) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + default: + return "", fmt.Errorf("不支持的背景图格式: %s", ext) + } + key := fmt.Sprintf("%s%s-%d%s", ProfileBannerPrefix, sanitizeObjectNameSegment(username), time.Now().UnixMilli(), ext) + if _, err := s.minio.PutObject(ctx, s.bucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{ + ContentType: imageContentType(ext), + }); err != nil { + return "", err + } + return key, nil +} + +// sanitizeObjectNameSegment 把用户名收敛成安全的 key 片段, +// 避免分隔符 / 路径穿越字符进入对象名。 +func sanitizeObjectNameSegment(name string) string { + out := make([]rune, 0, len(name)) + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': + out = append(out, r) + default: + out = append(out, '_') + } + } + if len(out) == 0 { + return "user" + } + return string(out) +} + // Exists 检查对象是否存在 func (s *FileService) Exists(ctx context.Context, key string) (bool, error) { if key == "" { diff --git a/backend/internal/service/search_service.go b/backend/internal/service/search_service.go index 34a224a..dfe9453 100644 --- a/backend/internal/service/search_service.go +++ b/backend/internal/service/search_service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sort" + "sync" "github.com/amll-dev/amll-hub/backend/internal/config" "github.com/meilisearch/meilisearch-go" @@ -345,3 +346,49 @@ func reorderHitsByGroup(hits []SearchHitResult) []SearchHitResult { } return result } + +// countFields 需要统计命中数的字段(与前端搜索结果页的 Tab 一一对应) +var countFields = []string{"all", "song", "artist", "album", "lyric", "id", "author"} + +// CountByFields 并发统计各字段的命中数,供搜索页 Tab 的数字徽章使用。 +// +// 每路查询只要 1 条命中:MeiliSearch 的 estimatedTotalHits 与 limit 无关, +// 限到 1 可以避免多余文档传输。 +func (s *SearchService) CountByFields(ctx context.Context, query string) (map[string]int64, error) { + totals := make(map[string]int64, len(countFields)) + if query == "" { + for _, f := range countFields { + totals[f] = 0 + } + return totals, nil + } + + var ( + mu sync.Mutex + wg sync.WaitGroup + firstErr error + ) + for _, field := range countFields { + field := field + wg.Add(1) + go func() { + defer wg.Done() + res, err := s.Search(ctx, SearchRequest{Query: query, Field: field, Limit: 1}) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = err + } + return + } + totals[field] = res.TotalHits + }() + } + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + return totals, nil +} diff --git a/backend/internal/service/user_activity_service.go b/backend/internal/service/user_activity_service.go index 5950e67..10eb4d8 100644 --- a/backend/internal/service/user_activity_service.go +++ b/backend/internal/service/user_activity_service.go @@ -41,8 +41,18 @@ func NewUserActivityService(db *gorm.DB) *UserActivityService { return &UserActivityService{db: db} } -// activitySources 四类活动的统一查询:归属人字段 + 时间字段 + 表名 -const activitySources = ` +// activityScope 活动统计的可见范围 +type activityScope int + +const ( + // activitySelf 本人视角:除草稿外的全部投稿 + 审核行为 + activitySelf activityScope = iota + // activityPublic 访客视角:只统计已通过投稿,且不含审核行为 + activityPublic +) + +// activitySourcesSelf 四类活动的统一查询:归属人字段 + 时间字段 + 表名 +const activitySourcesSelf = ` SELECT created_at AS day, 'lyric' AS type FROM submissions WHERE submitter = ? AND status <> 'draft' UNION ALL @@ -53,24 +63,74 @@ const activitySources = ` SELECT reviewed_at, 'review' FROM submission_review_history WHERE reviewer = ? ` +// activitySourcesPublic 访客视角:三张投稿表且仅 approved(审核不属于公开贡献) +const activitySourcesPublic = ` + SELECT created_at AS day, 'lyric' AS type FROM submissions + WHERE submitter = ? AND status = 'approved' + UNION ALL + SELECT created_at, 'daily' FROM daily_recommendations WHERE submitter = ? AND status = 'approved' + UNION ALL + SELECT created_at, 'search_ip' FROM search_ip_submissions WHERE submitter = ? AND status = 'approved' +` + +// activitySources 按范围返回 UNION 查询体 +func activitySources(scope activityScope) string { + if scope == activityPublic { + return activitySourcesPublic + } + return activitySourcesSelf +} + +// activitySourcesArgs 返回 UNION 查询体对应的用户名占位参数 +func activitySourcesArgs(scope activityScope, username string) []any { + args := []any{username, username, username} + if scope == activitySelf { + args = append(args, username) + } + return args +} + +// earliestActivitySQL 各表最早时间查询体(供前端切换年份) +func earliestActivitySQL(scope activityScope) string { + if scope == activityPublic { + return ` + SELECT MIN(t) FROM ( + SELECT MIN(created_at) AS t FROM submissions WHERE submitter = ? AND status = 'approved' + UNION ALL SELECT MIN(created_at) FROM daily_recommendations WHERE submitter = ? AND status = 'approved' + UNION ALL SELECT MIN(created_at) FROM search_ip_submissions WHERE submitter = ? AND status = 'approved' + )` + } + return ` + SELECT MIN(t) FROM ( + SELECT MIN(created_at) AS t FROM submissions WHERE submitter = ? + UNION ALL SELECT MIN(created_at) FROM daily_recommendations WHERE submitter = ? + UNION ALL SELECT MIN(created_at) FROM search_ip_submissions WHERE submitter = ? + UNION ALL SELECT MIN(reviewed_at) FROM submission_review_history WHERE reviewer = ? + )` +} + // GetActivity 聚合用户在指定年份的活动,按天分组。 // year <= 0 表示当前年份。 func (s *UserActivityService) GetActivity(ctx context.Context, username string, year int) (*UserActivity, error) { + return s.getActivity(ctx, username, year, activitySelf) +} + +// GetPublicActivity 访客视角的活动统计:只统计已通过投稿,不含审核行为。 +func (s *UserActivityService) GetPublicActivity(ctx context.Context, username string, year int) (*UserActivity, error) { + return s.getActivity(ctx, username, year, activityPublic) +} + +func (s *UserActivityService) getActivity(ctx context.Context, username string, year int, scope activityScope) (*UserActivity, error) { if year <= 0 { year = time.Now().Year() } start := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local) end := start.AddDate(1, 0, 0) - // 有记录的年份列表(供前端切换):四表最早时间 → 当前年 + // 有记录的年份列表(供前端切换):各表最早时间 → 当前年 var earliest *time.Time - if err := s.db.WithContext(ctx).Raw(` - SELECT MIN(t) FROM ( - SELECT MIN(created_at) AS t FROM submissions WHERE submitter = ? - UNION ALL SELECT MIN(created_at) FROM daily_recommendations WHERE submitter = ? - UNION ALL SELECT MIN(created_at) FROM search_ip_submissions WHERE submitter = ? - UNION ALL SELECT MIN(reviewed_at) FROM submission_review_history WHERE reviewer = ? - )`, username, username, username, username). + if err := s.db.WithContext(ctx). + Raw(earliestActivitySQL(scope), activitySourcesArgs(scope, username)...). Scan(&earliest).Error; err != nil { return nil, err } @@ -88,13 +148,14 @@ func (s *UserActivityService) GetActivity(ctx context.Context, username string, Cnt int `gorm:"column:cnt"` } var rows []row + args := append(activitySourcesArgs(scope, username), start, end) if err := s.db.WithContext(ctx).Raw(` SELECT day::date AS day, type, COUNT(*) AS cnt FROM ( - `+activitySources+` + `+activitySources(scope)+` ) t WHERE day >= ? AND day < ? GROUP BY 1, 2`, - username, username, username, username, start, end, + args..., ).Scan(&rows).Error; err != nil { return nil, err } diff --git a/backend/internal/service/user_profile_service.go b/backend/internal/service/user_profile_service.go new file mode 100644 index 0000000..e1f2671 --- /dev/null +++ b/backend/internal/service/user_profile_service.go @@ -0,0 +1,369 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/amll-dev/amll-hub/backend/internal/infrastructure" + "github.com/amll-dev/amll-hub/backend/internal/model" + "github.com/amll-dev/amll-hub/backend/internal/repository" + logrus "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// 主页资料字段上限(与迁移里的列长度一致) +const ( + MaxBioLen = 200 +) + +// PublicProfile 公开主页聚合资料。 +// +// 刻意不包含 email / phone / isAdmin / isReviewer——公开展示不需要也不应暴露。 +type PublicProfile struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + CreatedTime string `json:"createdTime,omitempty"` + Bio string `json:"bio"` + BannerKey string `json:"bannerKey"` + Birthday *string `json:"birthday"` + Gender string `json:"gender"` + IsSelf bool `json:"isSelf"` + Stats map[string]int64 `json:"stats"` +} + +// PublicSubmissionItem 公开主页的投稿列表项(裁剪过的 DTO,不含评审信息) +type PublicSubmissionItem struct { + ID int64 `json:"id"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + Language string `json:"language"` + Languages []string `json:"languages"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + FileUpdatedAt *time.Time `json:"fileUpdatedAt,omitempty"` +} + +// PublicSubmissionList 公开投稿列表结果 +type PublicSubmissionList struct { + Total int64 `json:"total"` + Items []PublicSubmissionItem `json:"items"` +} + +// PublicSubmissionQuery 公开投稿列表查询参数 +type PublicSubmissionQuery struct { + Status string + Language string + Sort string + Page int + Limit int +} + +// UpdateMyProfileRequest 更新本地主页资料 +type UpdateMyProfileRequest struct { + Bio string `json:"bio"` + Birthday *string `json:"birthday"` + Gender string `json:"gender"` +} + +// MyProfileFields 本地主页资料(写入成功后的回执) +type MyProfileFields struct { + Bio string `json:"bio"` + BannerKey string `json:"bannerKey"` + Birthday *string `json:"birthday"` + Gender string `json:"gender"` +} + +// UserProfileService 公开用户主页业务逻辑 +type UserProfileService struct { + profiles *repository.UserProfileRepo + subs *repository.SubmissionRepo + files *FileService + casdoor *infrastructure.CasdoorClient + org string +} + +// NewUserProfileService 创建服务 +func NewUserProfileService( + profiles *repository.UserProfileRepo, + subs *repository.SubmissionRepo, + files *FileService, + casdoor *infrastructure.CasdoorClient, + org string, +) *UserProfileService { + return &UserProfileService{ + profiles: profiles, + subs: subs, + files: files, + casdoor: casdoor, + org: org, + } +} + +// GetPublicProfile 聚合主页资料。 +// viewer 为空表示匿名访客;viewer == name 表示本人,能看到全部状态的计数。 +func (s *UserProfileService) GetPublicProfile(ctx context.Context, name, viewer string) (*PublicProfile, error) { + u, err := s.casdoor.GetUser(ctx, s.org, name) + if err != nil { + if errors.Is(err, infrastructure.ErrCasdoorUserNotFound) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("%w: %v", ErrUpstreamUnavailable, err) + } + + p, err := s.profiles.Get(ctx, name) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if p == nil { + p = &model.UserProfile{Username: name} + } + + isSelf := viewer != "" && viewer == name + + stats, err := s.subs.Stats(ctx, name) + if err != nil { + return nil, err + } + + return &PublicProfile{ + Name: name, + DisplayName: u.DisplayName, + Avatar: u.Avatar, + CreatedTime: normalizeCreatedTime(u.CreatedTime), + Bio: p.Bio, + BannerKey: p.BannerKey, + Birthday: formatBirthday(p.Birthday), + Gender: normalizeGender(p.Gender), + IsSelf: isSelf, + Stats: statsMap(stats, isSelf), + }, nil +} + +// ListPublicSubmissions 查询某用户的投稿列表。 +// 访客只能看到 approved:无论传入什么 status 都强制覆盖。 +func (s *UserProfileService) ListPublicSubmissions( + ctx context.Context, name string, isSelf bool, q PublicSubmissionQuery, +) (*PublicSubmissionList, error) { + status := q.Status + if status == "" { + status = "all" + } + if !isSelf { + status = model.StatusApproved + } + + res, err := s.subs.List(ctx, repository.ListQuery{ + Mode: "creator", + Submitter: name, + Status: status, + Language: q.Language, + Sort: q.Sort, + Page: q.Page, + Limit: q.Limit, + }) + if err != nil { + return nil, err + } + + items := make([]PublicSubmissionItem, 0, len(res.Items)) + for i := range res.Items { + items = append(items, toPublicSubmissionItem(&res.Items[i])) + } + return &PublicSubmissionList{Total: res.Total, Items: items}, nil +} + +// UpdateMyProfile 写入本地主页资料(不进 Casdoor) +func (s *UserProfileService) UpdateMyProfile( + ctx context.Context, username string, req UpdateMyProfileRequest, +) (*MyProfileFields, error) { + bio := strings.TrimSpace(req.Bio) + if utf8.RuneCountInString(bio) > MaxBioLen { + return nil, ErrInvalidInput + } + gender := strings.TrimSpace(req.Gender) + if gender == "" { + gender = model.GenderUnknown + } + if !isValidGender(gender) { + return nil, ErrInvalidInput + } + + birthday, err := parseBirthday(req.Birthday) + if err != nil { + return nil, err + } + + p := &model.UserProfile{ + Username: username, + Bio: bio, + Birthday: birthday, + Gender: gender, + } + if err := s.profiles.Upsert(ctx, p); err != nil { + return nil, err + } + + bannerKey := "" + if existing, err := s.profiles.Get(ctx, username); err == nil { + bannerKey = existing.BannerKey + } + + return &MyProfileFields{ + Bio: bio, + BannerKey: bannerKey, + Birthday: formatBirthday(birthday), + Gender: gender, + }, nil +} + +// UploadBanner 上传主页背景图并写回 key,返回新的对象 key +func (s *UserProfileService) UploadBanner(ctx context.Context, username string, content []byte) (string, error) { + if len(content) == 0 { + return "", ErrInvalidInput + } + ext := sniffImageExt(content) + if ext == "" || isSVG(content) { + return "", ErrInvalidInput + } + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + default: + return "", ErrInvalidInput + } + + oldKey := "" + old, err := s.profiles.Get(ctx, username) + switch { + case err == nil: + oldKey = old.BannerKey + case errors.Is(err, gorm.ErrRecordNotFound): + default: + return "", err + } + + key, err := s.files.UploadProfileBanner(ctx, username, content, ext) + if err != nil { + return "", err + } + + if err := s.profiles.UpdateBanner(ctx, username, key); err != nil { + // 落库失败时删掉刚传的对象,避免产生孤儿 + if delErr := s.files.Delete(ctx, key); delErr != nil { + logrus.WithError(delErr).Warn("cleanup profile banner after db failure failed") + } + return "", err + } + + if oldKey != "" && oldKey != key { + if delErr := s.files.Delete(ctx, oldKey); delErr != nil { + logrus.WithError(delErr).Warn("delete previous profile banner failed") + } + } + return key, nil +} + +// ---------- helpers ---------- + +func toPublicSubmissionItem(s *model.Submission) PublicSubmissionItem { + return PublicSubmissionItem{ + ID: s.ID, + Title: s.Title, + Artist: s.Artist, + Album: s.Album, + Language: s.Language, + Languages: s.Languages, + Status: s.Status, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + FileUpdatedAt: s.FileUpdatedAt, + } +} + +// statsMap 把仓库层的固定结构体展开成 map。 +// 访客视角只暴露 approved,避免泄露待审 / 被拒的数量。 +func statsMap(st *repository.Stats, isSelf bool) map[string]int64 { + if !isSelf { + return map[string]int64{model.StatusApproved: st.Approved} + } + return map[string]int64{ + model.StatusDraft: st.Draft, + model.StatusPending: st.Pending, + model.StatusReviewing: st.Reviewing, + model.StatusRevised: st.Revised, + model.StatusNeedRevision: st.NeedRevision, + model.StatusMissingAudio: st.MissingAudio, + model.StatusApproved: st.Approved, + model.StatusRejected: st.Rejected, + model.StatusClosed: st.Closed, + } +} + +func formatBirthday(t *time.Time) *string { + if t == nil || t.IsZero() { + return nil + } + v := t.Format("2006-01-02") + return &v +} + +// parseBirthday 空值 → nil;否则解析 YYYY-MM-DD 并做合理性校验 +func parseBirthday(raw *string) (*time.Time, error) { + if raw == nil { + return nil, nil + } + v := strings.TrimSpace(*raw) + if v == "" { + return nil, nil + } + t, err := time.ParseInLocation("2006-01-02", v, time.Local) + if err != nil { + return nil, ErrInvalidInput + } + now := time.Now() + if t.After(now) || t.Year() < 1900 { + return nil, ErrInvalidInput + } + return &t, nil +} + +func normalizeGender(g string) string { + if g == "" { + return model.GenderUnknown + } + return g +} + +// normalizeCreatedTime 过滤 Casdoor 返回的「未设置」创建时间。 +// +// Casdoor 对没有 createdTime 的用户会返回 Go 零值时间(0001-01-01T00:00:00Z), +// 直接透传会让主页显示 "0001-01-01",这里统一折算成空串由前端隐藏。 +func normalizeCreatedTime(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + // 解析不了的格式原样返回,不因为一个展示字段丢掉数据 + return raw + } + if t.Year() < 1900 { + return "" + } + return raw +} + +func isValidGender(g string) bool { + switch g { + case model.GenderMale, model.GenderFemale, model.GenderOther, model.GenderUnknown: + return true + } + return false +} diff --git a/backend/internal/service/user_search_service.go b/backend/internal/service/user_search_service.go new file mode 100644 index 0000000..4733e8c --- /dev/null +++ b/backend/internal/service/user_search_service.go @@ -0,0 +1,139 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/amll-dev/amll-hub/backend/internal/infrastructure" + "github.com/amll-dev/amll-hub/backend/internal/repository" +) + +// 用户搜索结果条数上限 +const ( + userSearchDefaultLimit = 20 + userSearchMaxLimit = 50 +) + +// UserSearchItem 用户搜索结果项 +type UserSearchItem struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Bio string `json:"bio"` + ApprovedCount int64 `json:"approvedCount"` + CreatedTime string `json:"createdTime,omitempty"` +} + +// UserSearchResult 用户搜索结果 +type UserSearchResult struct { + Items []UserSearchItem `json:"items"` + // Total 本次返回的条数;Casdoor 不提供命中总数,是否还有更多看 HasMore + Total int `json:"total"` + Limit int `json:"limit"` + HasMore bool `json:"hasMore"` +} + +// UserSearchService 按用户名 / 昵称搜索站点用户。 +// +// 账号权威数据在 Casdoor(昵称、头像、注册时间),主页资料(签名)在 user_profiles, +// 投稿数从 submissions 聚合,三者按 username 合并。 +type UserSearchService struct { + casdoor *infrastructure.CasdoorClient + profiles *repository.UserProfileRepo + subs *repository.SubmissionRepo +} + +// NewUserSearchService 创建服务 +func NewUserSearchService( + casdoor *infrastructure.CasdoorClient, + profiles *repository.UserProfileRepo, + subs *repository.SubmissionRepo, +) *UserSearchService { + return &UserSearchService{casdoor: casdoor, profiles: profiles, subs: subs} +} + +// Search 搜索用户,返回已聚合签名与已通过投稿数的结果 +func (s *UserSearchService) Search(ctx context.Context, keyword string, limit int) (*UserSearchResult, error) { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return &UserSearchResult{Items: []UserSearchItem{}, Limit: limit}, nil + } + if limit <= 0 { + limit = userSearchDefaultLimit + } + if limit > userSearchMaxLimit { + limit = userSearchMaxLimit + } + + // 多取一条用于判断是否还有更多 + users, err := s.casdoor.SearchUsers(ctx, keyword, limit+1) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUpstreamUnavailable, err) + } + hasMore := len(users) > limit + if hasMore { + users = users[:limit] + } + + names := make([]string, 0, len(users)) + for i := range users { + names = append(names, users[i].Name) + } + + // 两个补充查询失败不应该让整个搜索失败,降级为空即可 + profiles, err := s.profiles.ListByUsernames(ctx, names) + if err != nil { + profiles = nil + } + counts, err := s.subs.CountApprovedBySubmitters(ctx, names) + if err != nil { + counts = nil + } + + items := make([]UserSearchItem, 0, len(users)) + for i := range users { + u := users[i] + item := UserSearchItem{ + Name: u.Name, + DisplayName: u.DisplayName, + Avatar: u.Avatar, + CreatedTime: u.CreatedTime, + } + if p, ok := profiles[u.Name]; ok && p != nil { + item.Bio = p.Bio + } + item.ApprovedCount = counts[u.Name] + items = append(items, item) + } + + return &UserSearchResult{ + Items: items, + Total: len(items), + Limit: limit, + HasMore: hasMore, + }, nil +} + +// Count 返回可枚举的匹配数(达到 cap 即认为「更多」,与前端 N+ 的展示约定一致) +func (s *UserSearchService) Count(ctx context.Context, keyword string, cap int) (int, bool, error) { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return 0, false, nil + } + if cap <= 0 { + cap = userSearchDefaultLimit + } + users, err := s.casdoor.SearchUsers(ctx, keyword, cap+1) + if err != nil { + if errors.Is(err, infrastructure.ErrCasdoorUserNotFound) { + return 0, false, nil + } + return 0, false, err + } + if len(users) > cap { + return cap, true, nil + } + return len(users), false, nil +} diff --git a/backend/migrations/001_init.up.sql b/backend/migrations/001_init.up.sql index 9714cd6..c08319a 100644 --- a/backend/migrations/001_init.up.sql +++ b/backend/migrations/001_init.up.sql @@ -246,6 +246,8 @@ CREATE INDEX idx_submissions_language ON submissions(language); CREATE INDEX idx_submissions_languages ON submissions USING GIN (languages); CREATE INDEX idx_submissions_created ON submissions(created_at DESC); CREATE INDEX idx_submissions_status_created ON submissions(status, created_at DESC); +-- 公开主页投稿列表:按用户 + 状态 + 时间倒序 +CREATE INDEX idx_submissions_submitter_status_created ON submissions(submitter, status, created_at DESC); CREATE INDEX idx_submissions_search ON submissions USING gin (to_tsvector('simple', coalesce(title,'') || ' ' || @@ -447,6 +449,35 @@ ALTER TABLE notifications ADD CONSTRAINT ck_notifications_type CHECK (type IN ('system', 'review', 'submission', 'comment')); +-- 用户公开主页扩展资料表 +-- 项目没有 users 表,username 对应 Casdoor name / JWT claims.name,不建外键; +-- 只存 Casdoor 里没有的展示字段(签名 / 背景图 / 生日 / 性别)。 +CREATE TABLE user_profiles ( + username VARCHAR(100) PRIMARY KEY, + bio VARCHAR(200) NOT NULL DEFAULT '', + banner_key VARCHAR(500) NOT NULL DEFAULT '', + birthday DATE, + gender VARCHAR(16) NOT NULL DEFAULT 'unknown', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE user_profiles IS '用户公开主页扩展资料(Casdoor 之外的本地字段)'; +COMMENT ON COLUMN user_profiles.username IS 'Casdoor username / JWT claims.name'; +COMMENT ON COLUMN user_profiles.banner_key IS 'MinIO 对象 key,前缀 profile-banner/;空串表示未设置'; +COMMENT ON COLUMN user_profiles.birthday IS '生日,DATE 无时区;NULL 表示未填写'; +COMMENT ON COLUMN user_profiles.gender IS 'male / female / other / unknown'; + +-- gender 用 varchar + CHECK,不用 PG enum:后续加值只需改约束,回滚也更简单 +ALTER TABLE user_profiles + ADD CONSTRAINT ck_user_profiles_gender + CHECK (gender IN ('male', 'female', 'other', 'unknown')); + +CREATE TRIGGER trg_user_profiles_updated_at + BEFORE UPDATE ON user_profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + -- GitHub 绑定表:一个站点用户(JWT claims.name)最多绑定一个 GitHub 账号 CREATE TABLE user_github_bindings ( id BIGSERIAL PRIMARY KEY, diff --git a/frontend/src/atoms/profileForm.ts b/frontend/src/atoms/profileForm.ts index 504fd1c..6116ae6 100644 --- a/frontend/src/atoms/profileForm.ts +++ b/frontend/src/atoms/profileForm.ts @@ -22,6 +22,10 @@ export const editMsgAtom = atom(null); export const profileMsgAtom = atom(null); /** 密码修改反馈消息 */ export const pwdMsgAtom = atom(null); +/** 主页资料(签名/地区/生日/性别)保存反馈消息 */ +export const localProfileMsgAtom = atom(null); +/** 主页背景图上传反馈消息 */ +export const bannerMsgAtom = atom(null); /** 复位个人中心表单状态(页面卸载时调用,保持与原 useState 卸载即重置一致的语义) */ export function resetProfileForm() { @@ -33,4 +37,6 @@ export function resetProfileForm() { store.set(editMsgAtom, null); store.set(profileMsgAtom, null); store.set(pwdMsgAtom, null); + store.set(localProfileMsgAtom, null); + store.set(bannerMsgAtom, null); } diff --git a/frontend/src/atoms/search.ts b/frontend/src/atoms/search.ts index e854921..0939d6b 100644 --- a/frontend/src/atoms/search.ts +++ b/frontend/src/atoms/search.ts @@ -1,10 +1,10 @@ import { atom } from 'jotai'; -import type { SearchField } from '@/lib/types'; +import type { SearchTab } from '@/lib/types'; -/** 已提交的搜索请求(debounce 后 / 立即提交 / 翻页才更新),驱动 useQuery */ +/** 已提交的搜索请求(debounce 后 / 立即提交 / 翻页 / 切 Tab 才更新),驱动 useQuery */ export interface CommittedSearch { q: string; - field: SearchField; + tab: SearchTab; page: number; } @@ -12,8 +12,8 @@ export interface CommittedSearch { /** 输入框当前值(未提交) */ export const searchQueryAtom = atom(''); -/** 搜索字段 */ -export const searchFieldAtom = atom('all'); +/** 当前搜索 Tab(歌曲各字段 + 用户) */ +export const searchTabAtom = atom('all'); /** 是否已进入搜索模式(控制页面切换;基于"是否已发起过搜索"而非 query 非空) */ export const searchHasQueryAtom = atom(false); /** 当前页码(1-based) */ diff --git a/frontend/src/boot/SearchBoot.tsx b/frontend/src/boot/SearchBoot.tsx index 0705895..fd35e6b 100644 --- a/frontend/src/boot/SearchBoot.tsx +++ b/frontend/src/boot/SearchBoot.tsx @@ -3,7 +3,7 @@ import { useAtom, useAtomValue } from 'jotai'; import { useLocation, useNavigate } from 'react-router-dom'; import { searchQueryAtom, - searchFieldAtom, + searchTabAtom, searchHasQueryAtom, searchPageAtom, searchCommittedAtom, @@ -28,7 +28,7 @@ export function registerSearchInput(el: HTMLInputElement | null) { */ export function SearchBoot() { const query = useAtomValue(searchQueryAtom); - const field = useAtomValue(searchFieldAtom); + const tab = useAtomValue(searchTabAtom); const hasQuery = useAtomValue(searchHasQueryAtom); const [, setHasQuery] = useAtom(searchHasQueryAtom); const [, setPage] = useAtom(searchPageAtom); @@ -38,7 +38,7 @@ export function SearchBoot() { const hasQueryRef = useRef(false); - // query/field 变化重置到第 1 页;debounce 后提交搜索 + // query/tab 变化重置到第 1 页;debounce 后提交搜索 useEffect(() => { const trimmed = query.trim(); if (!trimmed) { @@ -62,10 +62,10 @@ export function SearchBoot() { window.scrollTo(0, 0); hasQueryRef.current = true; setHasQuery(true); - setCommitted({ q: trimmed, field, page: 1 }); + setCommitted({ q: trimmed, tab, page: 1 }); }, DEBOUNCE_MS); return () => clearTimeout(timer); - }, [query, field, setHasQuery, setPage, setCommitted]); + }, [query, tab, setHasQuery, setPage, setCommitted]); // 监听 query 变化:在非首页输入搜索词时,导航回首页展示结果 const prevQuery = useRef(''); diff --git a/frontend/src/components/DatePicker.tsx b/frontend/src/components/DatePicker.tsx index 5f3f36b..bc1f8b9 100644 --- a/frontend/src/components/DatePicker.tsx +++ b/frontend/src/components/DatePicker.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'; import { buttonTap } from '@/lib/motion'; +import { cn } from '@/lib/utils'; const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; const MONTHS = [ @@ -44,10 +45,22 @@ export function DatePicker({ value, onChange, placeholder = '选择日期', + max, + id, + className, + 'aria-invalid': ariaInvalid, + 'aria-describedby': ariaDescribedBy, }: { value: string; onChange: (dateKey: string) => void; placeholder?: string; + /** 可选上限(含当天),YYYY-MM-DD;超出范围的日子不可点 */ + max?: string; + id?: string; + /** 触发器附加样式 */ + className?: string; + 'aria-invalid'?: boolean; + 'aria-describedby'?: string; }) { const today = useMemo(() => new Date(), []); const todayKey = formatDateKey(today); @@ -155,12 +168,18 @@ export function DatePicker({ {/* 触发按钮 */} - - - {open && ( - - {fieldOptions.map((o) => ( -
  • - -
  • - ))} -
    - )} -
    - - ); -} - -export function SearchBar({ - query, - field, - loading, - onQueryChange, - onFieldChange, - onSubmit, - compact = false, -}: Props) { +export function SearchBar({ query, loading, onQueryChange, onSubmit, compact = false }: Props) { const { registerInput, hasQuery } = useSearchContext(); // 显示文本 @@ -158,8 +67,6 @@ export function SearchBar({ return (
    - -
    diff --git a/frontend/src/components/SearchResults.tsx b/frontend/src/components/SearchResults.tsx index 3514215..65e0dab 100644 --- a/frontend/src/components/SearchResults.tsx +++ b/frontend/src/components/SearchResults.tsx @@ -23,6 +23,8 @@ import { downloadBlobFile } from '@/lib/download'; import { listItem, staggerContainer } from '@/lib/motion'; import { formatDate, formatNumber } from '@/lib/format'; import { sanitizeHighlightHtml } from '@/lib/markup'; +import { SearchTabs } from '@/components/search/SearchTabs'; +import { RelatedUsers, UserSearchList } from '@/components/search/UserSearchList'; import type { SearchHit, SearchIpMatchResult } from '@/lib/types'; // 结果区顶部的锚点 id,分页切换时滚到此位置 @@ -202,7 +204,9 @@ function ResultActions({ hit }: { hit: SearchHit }) { } export function SearchResults() { - const { hits, loading, error, query, totalHits } = useSearchContext(); + const { hits, loading, error, query, totalHits, tab, setTab, counts, userMore } = + useSearchContext(); + const isUserTab = tab === 'user'; const [ipMatch, setIpMatch] = useState(null); // 当 hits 变化时,收集所有 artists 去重并请求 IP 匹配 @@ -246,15 +250,21 @@ export function SearchResults() { > {/* 分页切换的滚动锚点:scroll-mt 预留顶栏高度,避免贴顶 */}
    -
    + + {/* 搜索范围 Tab */} + + +

    搜索结果

    - {loading && } + {loading && !isUserTab && }

    - “{query}” {loading ? '搜索中…' : `共 ${totalHits} 条`} + “{query}” {isUserTab ? '的用户匹配' : loading ? '搜索中…' : `共 ${totalHits} 条`}

    - {error ? ( + {isUserTab ? ( + + ) : error ? (

    {error}

    ) : showSkeleton ? (
    @@ -440,7 +450,8 @@ export function SearchResults() { )} - + {!isUserTab && } + {tab === 'all' && } ); } diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx index 464e130..d4e0dfe 100644 --- a/frontend/src/components/UserMenu.tsx +++ b/frontend/src/components/UserMenu.tsx @@ -25,6 +25,7 @@ import { ShieldCheck, Sun, User, + SquareUser, } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { themeAtom, type ThemePreference } from '@/atoms/theme'; @@ -92,6 +93,18 @@ export function UserMenu() { {/* 菜单项 */}
    + + preloadRoute(`/user/${user.name}`)} + className="cursor-pointer" + > + + 我的主页 + + + = { +const LANG_MAP: Record = { zh: 'zh', cn: 'zh', chs: 'zh', @@ -44,6 +47,23 @@ const LANG_MAP: Record = { 'ko-KR': 'ko', }; +// 语言选项:value 为后端枚举代码,label 为中文 +const LANG_OPTIONS: { value: LangCode; label: string }[] = [ + { value: 'zh', label: '汉语' }, + { value: 'en', label: '英语' }, + { value: 'ja', label: '日语' }, + { value: 'ko', label: '韩语' }, + { value: 'others', label: '其他' }, +]; + +const LANG_LABELS = Object.fromEntries(LANG_OPTIONS.map((l) => [l.value, l.label])) as Record< + string, + string +>; + +// 语言总上限与后端 model.MaxLanguages 保持一致 +const MAX_LANGUAGES = 10; + /** 歌词投稿表单 */ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { const navigate = useNavigate(); @@ -60,6 +80,10 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { languages: string[]; otherLangs: string[]; tags: string[]; + /** 语言是自动识别填进去的,下次选文件仍可重新识别 */ + langAuto?: boolean; + /** 草稿里的自动标签,换文件时用来顶掉上一份文件的自动标签 */ + autoTags?: string[]; }>('lyric-submit'); const [notes, setNotes] = useState(draft?.notes ?? ''); const [languages, setLanguages] = useState(draft?.languages ?? []); @@ -82,11 +106,55 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { ); const fileInputRef = useRef(null); - // 选择文件后立即校验 + // 已自动识别的语言(仅用于提示文案);用户手动调整后清空 + const [autoLangs, setAutoLangs] = useState([]); + // 已自动添加的标签:和手输标签同在一个列表里,这里只记来源,便于换文件时替换 + const [autoTags, setAutoTags] = useState(() => draft?.autoTags ?? []); + const autoTagsRef = useRef(autoTags); + // 用户手动选过语言:草稿里带非自动填充的语言时也视作手选 + const langTouchedRef = useRef( + (draft?.languages?.length ?? 0) > 0 + ? draft?.langAuto !== true + : (draft?.otherLangs?.length ?? 0) > 0 + ); + // 识别来源优先级:2=文件元数据声明,1=正文推断;不覆盖更高优先级的已有结果 + const autoPriorityRef = useRef(0); + // 连选文件时的异步竞态保护 + const loadSeqRef = useRef(0); + + const applyAutoLanguages = useCallback((codes: readonly LangCode[], priority: number) => { + if (langTouchedRef.current || priority < autoPriorityRef.current) return; + const next = codes.filter((code) => code in LANG_LABELS); + if (next.length === 0) return; + autoPriorityRef.current = priority; + setLanguages(next); + // 自动识别给不出自定义语言名,「其他」的输入留空由用户补 + setOtherLangs([]); + setOtherLangInput(''); + setAutoLangs(next); + }, []); + + // 应用自动标签:追加到标签列表并去重,手输标签原样保留 + const applyAutoTags = useCallback((next: string[]) => { + autoTagsRef.current = next; + setAutoTags(next); + setTags((current) => [...current, ...next.filter((tag) => !current.includes(tag))]); + }, []); + + // 选择文件后立即校验;seq 用于丢弃连选文件时旧文件的迟到结果 const validateMutation = useMutation({ - mutationFn: async (f: File) => api.validateTtml(await f.text()), - onSuccess: (result) => setValidation(result), - onError: (err) => setValidateError(err instanceof Error ? err.message : '校验请求失败'), + mutationFn: async (params: { content: string; seq: number }) => ({ + seq: params.seq, + result: await api.validateTtml(params.content), + }), + onSuccess: ({ seq, result }) => { + if (seq !== loadSeqRef.current) return; + setValidation(result); + }, + onError: (err, vars) => { + if (vars.seq !== loadSeqRef.current) return; + setValidateError(err instanceof Error ? err.message : '校验请求失败'); + }, }); const validating = validateMutation.isPending; @@ -165,6 +233,8 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { const submitting = submitMutation.isPending; const reset = () => { + // 让在途的本地解析 / 校验结果作废 + loadSeqRef.current++; setFile(null); setValidation(null); setValidateError(''); @@ -173,9 +243,14 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { setLanguages([]); setOtherLangs([]); setOtherLangInput(''); + setAutoLangs([]); + langTouchedRef.current = false; + autoPriorityRef.current = 0; unrearranged.reset(); setTags([]); setTagInput(''); + setAutoTags([]); + autoTagsRef.current = []; setShowTtml(false); setAgreed(false); setAudioFiles([]); @@ -188,8 +263,8 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { useEffect(() => { const empty = !notes && languages.length === 0 && otherLangs.length === 0 && tags.length === 0; if (empty) return; - setDraft({ notes, languages, otherLangs, tags }); - }, [notes, languages, otherLangs, tags, setDraft, clearDraft]); + setDraft({ notes, languages, otherLangs, tags, langAuto: autoLangs.length > 0, autoTags }); + }, [notes, languages, otherLangs, tags, autoLangs, autoTags, setDraft]); const addTag = () => { const t = tagInput.trim(); @@ -203,16 +278,56 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { setTags(tags.filter((x) => x !== t)); }; - const handleFile = (f: File) => { - if (!f.name.toLowerCase().endsWith('.ttml')) { - setValidateError('请选择 .ttml 格式的文件'); + // 读文件 → 后端校验 + 本地解析推断语言/标签(两路互不阻塞) + const loadFile = async (f: File) => { + const seq = ++loadSeqRef.current; + // 换文件后重新走一遍识别优先级(旧文件的元数据语言不再压制新文件) + autoPriorityRef.current = 0; + // 上一份文件的自动识别结果不保留,避免识别失败时留下别的歌的语言 + if (!langTouchedRef.current) { + setLanguages([]); + setOtherLangs([]); + setAutoLangs([]); + } + // 上一份文件带来的自动标签先摘掉,避免换了歌还留着上一首的标签 + const previousAutoTags = autoTagsRef.current; + if (previousAutoTags.length > 0) { + setTags((current) => current.filter((tag) => !previousAutoTags.includes(tag))); + } + autoTagsRef.current = []; + setAutoTags([]); + let text: string; + try { + text = await f.text(); + } catch { + setValidateError('文件读取失败,请重新选择'); return; } + if (seq !== loadSeqRef.current) return; + // 读完后一次性置态,避免文件卡片先出现、校验态后到的闪烁 setFile(f); setValidation(null); setValidateError(''); setSubmitMsg(null); - validateMutation.mutate(f); + validateMutation.mutate({ content: text, seq }); + + // 语言 / 标签识别复用编辑器同款 wasm 解析;解析失败就只跳过自动填充 + const { parseTTML } = await import('@/lib/ttml-processor'); + const parsed = await parseTTML(text); + if (seq !== loadSeqRef.current || !parsed.success) return; + applyAutoLanguages( + detectLanguages(parsed.data.lines).map((hit) => hit.code), + 1 + ); + applyAutoTags(detectAutoTags({ metadata: parsed.data.metadata, lines: parsed.data.lines })); + }; + + const handleFile = (f: File) => { + if (!f.name.toLowerCase().endsWith('.ttml')) { + setValidateError('请选择 .ttml 格式的文件'); + return; + } + void loadFile(f); }; const onDrop = (e: React.DragEvent) => { @@ -222,28 +337,24 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { if (f) handleFile(f); }; - // 语言选项:value 为后端枚举代码,label 为中文 - const LANG_OPTIONS: { value: string; label: string }[] = [ - { value: 'zh', label: '汉语' }, - { value: 'en', label: '英语' }, - { value: 'ja', label: '日语' }, - { value: 'ko', label: '韩语' }, - { value: 'others', label: '其他' }, - ]; + // 文件元数据里显式声明了语言时以它为准,优先级高于正文推断 useEffect(() => { - if (!validation?.valid) return; - const raw = validation.metadata?.language?.trim(); + const raw = validation?.metadata?.language?.trim(); if (!raw) return; - const matched = LANG_MAP[raw] || LANG_MAP[raw.toLowerCase()]; - // 只在用户还没手动选过时自动填,不覆盖已有勾选 - if (matched) setLanguages((prev) => (prev.length > 0 ? prev : [matched])); - }, [validation]); + const matched = LANG_MAP[raw] ?? LANG_MAP[raw.toLowerCase()]; + if (matched) applyAutoLanguages([matched], 2); + }, [validation, applyAutoLanguages]); - // 语言总上限与后端 model.MaxLanguages 保持一致 - const MAX_LANGUAGES = 10; const langCount = languages.length + otherLangs.length; + // 用户一动语言就锁定为手选,不再被自动识别覆盖 + const markLangManual = () => { + langTouchedRef.current = true; + setAutoLangs([]); + }; + const toggleLang = (v: string) => { + markLangManual(); setLanguages((prev) => { if (prev.includes(v)) { // 「其他」取消勾选时连带清掉已输入的自定义语言 @@ -258,6 +369,7 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { const addOtherLang = () => { const t = otherLangInput.trim(); if (!t) return; + markLangManual(); if (otherLangs.includes(t)) { setOtherLangInput(''); return; @@ -267,7 +379,10 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { setOtherLangInput(''); }; - const removeOtherLang = (t: string) => setOtherLangs(otherLangs.filter((x) => x !== t)); + const removeOtherLang = (t: string) => { + markLangManual(); + setOtherLangs(otherLangs.filter((x) => x !== t)); + }; // 表单主体是否展开:校验模式等 valid;未重排模式等元数据解析返回(不论 valid) const doSubmit = (status: 'pending' | 'draft') => { @@ -592,6 +707,17 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { )}
    )} + + {/* 自动识别提示:用户一手动调整就消失 */} + {autoLangs.length > 0 && ( +

    + + + 已根据歌词内容自动选择:{autoLangs.map((code) => LANG_LABELS[code]).join('、')} + {autoLangs.includes('others') && ',请在「其他」中补充具体语言名'} + +

    + )} )} @@ -628,23 +754,34 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { /> {tags.length > 0 && (
    - {tags.map((t) => ( - - {t} - - - ))} + {auto && } + {t} + + + ); + })}
    )} + {autoTags.length > 0 && ( +

    + + 带星标的标签由文件元数据与歌词特性自动识别,可手动删除 +

    + )} )} diff --git a/frontend/src/components/profile/ContributionWall.tsx b/frontend/src/components/profile/ContributionWall.tsx index ec1d606..858a801 100644 --- a/frontend/src/components/profile/ContributionWall.tsx +++ b/frontend/src/components/profile/ContributionWall.tsx @@ -102,18 +102,28 @@ function formatDate(d: Date): string { const TODAY_KEY = formatDate(new Date()); export interface ContributionWallProps { - /** 当前登录用户名(用于 queryKey 隔离不同账号的缓存) */ + /** 目标用户名(同时用于 queryKey 隔离不同账号的缓存) */ username: string; + /** + * me:本人视角,走 /users/me/activity(含审核行为) + * user:访客视角,走 /users/:name/activity(只统计已通过投稿) + */ + scope?: 'me' | 'user'; } /** 个人中心活动绿墙 */ -export function ContributionWall({ username }: ContributionWallProps) { +export function ContributionWall({ username, scope = 'me' }: ContributionWallProps) { const thisYear = new Date().getFullYear(); const [year, setYear] = useState(thisYear); + const isPublic = scope === 'user'; const { data, isPending } = useQuery({ - queryKey: [...queryKeys.userActivity(year), username], - queryFn: () => api.getUserActivity(year), + queryKey: isPublic + ? queryKeys.userActivityOf(username, year) + : [...queryKeys.userActivity(year), username], + queryFn: isPublic + ? () => api.getUserActivityOf(username, year) + : () => api.getUserActivity(year), staleTime: 60_000, }); diff --git a/frontend/src/components/profile/HomepageProfileSection.tsx b/frontend/src/components/profile/HomepageProfileSection.tsx new file mode 100644 index 0000000..525991e --- /dev/null +++ b/frontend/src/components/profile/HomepageProfileSection.tsx @@ -0,0 +1,312 @@ +import { useEffect, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAtom } from 'jotai'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { IdCard, Image as ImageIcon, Loader2, Save, Upload } from 'lucide-react'; +import { z } from 'zod'; +import { bannerMsgAtom, localProfileMsgAtom } from '@/atoms/profileForm'; +import { useAuth } from '@/hooks/useAuth'; +import { api } from '@/lib/api'; +import { queryKeys } from '@/lib/query'; +import { cn } from '@/lib/utils'; +import { compressBanner } from '@/lib/image'; +import { buttonTap } from '@/lib/motion'; +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/DatePicker'; +import { Textarea } from '@/components/ui/textarea'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { SectionCard } from '@/components/profile/SectionCard'; +import type { Gender, PublicUserProfile } from '@/lib/types'; + +const GENDER_OPTIONS: { value: Gender; label: string }[] = [ + { value: 'unknown', label: '保密' }, + { value: 'male', label: '男' }, + { value: 'female', label: '女' }, + { value: 'other', label: '其他' }, +]; + +const BIO_MAX = 200; + +const localProfileSchema = z.object({ + bio: z.string().trim().max(BIO_MAX, `签名最多 ${BIO_MAX} 字`), + birthday: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式应为 YYYY-MM-DD') + .or(z.literal('')), + gender: z.enum(['male', 'female', 'other', 'unknown']), +}); +type LocalProfileValues = z.infer; + +/** + * 个人中心「主页资料 + 主页背景图」两块。 + * + * 资料存在本站的 user_profiles 表,读取复用公开主页的聚合接口。 + */ +export function HomepageProfileSection() { + const { user } = useAuth(); + const username = user?.name ?? ''; + + const profileQuery = useQuery({ + queryKey: queryKeys.userProfile(username), + queryFn: () => api.getUserProfile(username), + enabled: username !== '', + staleTime: 60_000, + }); + + if (profileQuery.isPending) { + return ( +
    + } title="主页资料"> + + + } title="主页背景图"> + + +
    + ); + } + + if (profileQuery.error || !profileQuery.data) { + return ( + } title="主页资料" description="这些字段展示在 /user/你的用户名"> +

    + {profileQuery.error instanceof Error + ? profileQuery.error.message + : '加载失败,请稍后重试'} +

    +
    + ); + } + + // 数据就绪后才挂载表单,避免 useForm 的 defaultValues 拿到空值 + return ( +
    + + +
    + ); +} + +function LocalProfileForm({ username, profile }: { username: string; profile: PublicUserProfile }) { + const queryClient = useQueryClient(); + const [msg, setMsg] = useAtom(localProfileMsgAtom); + + const form = useForm({ + resolver: zodResolver(localProfileSchema), + defaultValues: { + bio: profile.bio, + birthday: profile.birthday ?? '', + gender: profile.gender, + }, + }); + + const mutation = useMutation({ + mutationFn: (v: LocalProfileValues) => + api.updateMyProfile({ + bio: v.bio, + birthday: v.birthday === '' ? null : v.birthday, + gender: v.gender, + }), + onMutate: () => setMsg(null), + onSuccess: (fields) => { + queryClient.setQueryData(queryKeys.userProfile(username), (prev) => + prev ? { ...prev, ...fields } : prev + ); + setMsg({ ok: true, text: '主页资料已保存' }); + }, + onError: (err: Error) => setMsg({ ok: false, text: err.message || '保存失败' }), + }); + + // 用本地时区拼日期:toISOString 是 UTC,东八区早上 8 点前会算成昨天 + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + + return ( + } title="主页资料" description="展示在个人主页 "> + + mutation.mutate(v))} className="space-y-4"> + ( + + 个性签名 + +