| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639 |
- package handler
- import (
- "context"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/handler/dto"
- "github.com/Wei-Shaw/sub2api/internal/handler/quotaview"
- "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"
- )
- // UserHandler handles user-related requests
- type UserHandler struct {
- userService *service.UserService
- authService *service.AuthService
- emailService *service.EmailService
- emailCache service.EmailCache
- affiliateService *service.AffiliateService
- userPlatformQuotaRepo service.UserPlatformQuotaRepository
- }
- // NewUserHandler creates a new UserHandler
- func NewUserHandler(
- userService *service.UserService,
- authService *service.AuthService,
- emailService *service.EmailService,
- emailCache service.EmailCache,
- affiliateService *service.AffiliateService,
- userPlatformQuotaRepo service.UserPlatformQuotaRepository,
- ) *UserHandler {
- return &UserHandler{
- userService: userService,
- authService: authService,
- emailService: emailService,
- emailCache: emailCache,
- affiliateService: affiliateService,
- userPlatformQuotaRepo: userPlatformQuotaRepo,
- }
- }
- // GetMyPlatformQuotas GET /user/platform-quotas
- // 返回当前 JWT 用户的 platform quota 状态。
- // D14: 对每条记录逐档判断窗口过期,过期档位 usage=0、window_resets_at=null(不写 DB)
- func (h *UserHandler) GetMyPlatformQuotas(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- if h.userPlatformQuotaRepo == nil {
- response.Success(c, map[string]any{"platform_quotas": []any{}})
- return
- }
- records, err := h.userPlatformQuotaRepo.ListByUser(c.Request.Context(), subject.UserID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- now := time.Now().UTC()
- out := make([]map[string]any, 0, len(records))
- for _, r := range records {
- out = append(out, quotaview.LazyZeroQuotaForResponse(r, now, false))
- }
- response.Success(c, map[string]any{"platform_quotas": out})
- }
- // ChangePasswordRequest represents the change password request payload
- type ChangePasswordRequest struct {
- OldPassword string `json:"old_password" binding:"required"`
- NewPassword string `json:"new_password" binding:"required,min=6"`
- }
- // UpdateProfileRequest represents the update profile request payload
- type UpdateProfileRequest struct {
- Username *string `json:"username"`
- AvatarURL *string `json:"avatar_url"`
- BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
- BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
- }
- type userProfileResponse struct {
- dto.User
- AvatarURL string `json:"avatar_url,omitempty"`
- AvatarSource *userProfileSourceContext `json:"avatar_source,omitempty"`
- UsernameSource *userProfileSourceContext `json:"username_source,omitempty"`
- DisplayNameSource *userProfileSourceContext `json:"display_name_source,omitempty"`
- NicknameSource *userProfileSourceContext `json:"nickname_source,omitempty"`
- ProfileSources map[string]*userProfileSourceContext `json:"profile_sources,omitempty"`
- Identities service.UserIdentitySummarySet `json:"identities"`
- AuthBindings map[string]service.UserIdentitySummary `json:"auth_bindings"`
- IdentityBindings map[string]service.UserIdentitySummary `json:"identity_bindings"`
- EmailBound bool `json:"email_bound"`
- LinuxDoBound bool `json:"linuxdo_bound"`
- OIDCBound bool `json:"oidc_bound"`
- WeChatBound bool `json:"wechat_bound"`
- DingTalkBound bool `json:"dingtalk_bound"`
- }
- type userProfileSourceContext struct {
- Provider string `json:"provider,omitempty"`
- Source string `json:"source,omitempty"`
- }
- // GetProfile handles getting user profile
- // GET /api/v1/users/me
- func (h *UserHandler) GetProfile(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- userData, err := h.userService.GetProfile(c.Request.Context(), subject.UserID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, userData)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, profileResp)
- }
- // ChangePassword handles changing user password
- // POST /api/v1/users/me/password
- func (h *UserHandler) ChangePassword(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- var req ChangePasswordRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- svcReq := service.ChangePasswordRequest{
- CurrentPassword: req.OldPassword,
- NewPassword: req.NewPassword,
- }
- err := h.userService.ChangePassword(c.Request.Context(), subject.UserID, svcReq)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Password changed successfully"})
- }
- // UpdateProfile handles updating user profile
- // PUT /api/v1/users/me
- func (h *UserHandler) UpdateProfile(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- var req UpdateProfileRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- svcReq := service.UpdateProfileRequest{
- Username: req.Username,
- AvatarURL: req.AvatarURL,
- BalanceNotifyEnabled: req.BalanceNotifyEnabled,
- BalanceNotifyThreshold: req.BalanceNotifyThreshold,
- }
- updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, profileResp)
- }
- // GetAffiliate returns the current user's affiliate details.
- // GET /api/v1/user/aff
- func (h *UserHandler) GetAffiliate(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- detail, err := h.affiliateService.GetAffiliateDetail(c.Request.Context(), subject.UserID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, detail)
- }
- // TransferAffiliateQuota transfers all available affiliate quota into current balance.
- // POST /api/v1/user/aff/transfer
- func (h *UserHandler) TransferAffiliateQuota(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- transferred, balance, err := h.affiliateService.TransferAffiliateQuota(c.Request.Context(), subject.UserID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{
- "transferred_quota": transferred,
- "balance": balance,
- })
- }
- type StartIdentityBindingRequest struct {
- Provider string `json:"provider" binding:"required"`
- RedirectTo string `json:"redirect_to"`
- }
- type BindEmailIdentityRequest struct {
- Email string `json:"email" binding:"required,email"`
- VerifyCode string `json:"verify_code" binding:"required"`
- Password string `json:"password" binding:"required"`
- }
- type SendEmailBindingCodeRequest struct {
- Email string `json:"email" binding:"required,email"`
- }
- // StartIdentityBinding returns the backend authorize URL for starting a third-party identity bind flow.
- // POST /api/v1/user/auth-identities/bind/start
- func (h *UserHandler) StartIdentityBinding(c *gin.Context) {
- if _, ok := middleware2.GetAuthSubjectFromContext(c); !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- var req StartIdentityBindingRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- result, err := h.userService.PrepareIdentityBindingStart(c.Request.Context(), service.StartUserIdentityBindingRequest{
- Provider: req.Provider,
- RedirectTo: req.RedirectTo,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, result)
- }
- // BindEmailIdentity verifies and binds a local email identity for the current user.
- // POST /api/v1/user/account-bindings/email
- func (h *UserHandler) BindEmailIdentity(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- if h.authService == nil {
- response.InternalError(c, "Auth service not configured")
- return
- }
- var req BindEmailIdentityRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- updatedUser, err := h.authService.BindEmailIdentity(
- c.Request.Context(),
- subject.UserID,
- req.Email,
- req.VerifyCode,
- req.Password,
- )
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, profileResp)
- }
- // UnbindIdentity removes a third-party sign-in provider from the current user.
- // DELETE /api/v1/user/account-bindings/:provider
- func (h *UserHandler) UnbindIdentity(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- updatedUser, unbound, err := h.userService.UnbindUserAuthProviderWithResult(
- c.Request.Context(),
- subject.UserID,
- c.Param("provider"),
- )
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if unbound && h.authService != nil {
- if err := h.authService.RevokeAllUserTokens(c.Request.Context(), subject.UserID); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- }
- profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, profileResp)
- }
- // SendEmailBindingCode sends a verification code for the current user's email binding flow.
- // POST /api/v1/user/account-bindings/email/send-code
- func (h *UserHandler) SendEmailBindingCode(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- if h.authService == nil {
- response.InternalError(c, "Auth service not configured")
- return
- }
- var req SendEmailBindingCodeRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if err := h.authService.SendEmailIdentityBindCode(c.Request.Context(), subject.UserID, req.Email, c.GetHeader("Accept-Language")); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Verification code sent successfully"})
- }
- // SendNotifyEmailCodeRequest represents the request to send notify email verification code
- type SendNotifyEmailCodeRequest struct {
- Email string `json:"email" binding:"required,email"`
- }
- // SendNotifyEmailCode sends verification code to extra notification email
- // POST /api/v1/user/notify-email/send-code
- func (h *UserHandler) SendNotifyEmailCode(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- var req SendNotifyEmailCodeRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache, c.GetHeader("Accept-Language"))
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Verification code sent successfully"})
- }
- // VerifyNotifyEmailRequest represents the request to verify and add notify email
- type VerifyNotifyEmailRequest struct {
- Email string `json:"email" binding:"required,email"`
- Code string `json:"code" binding:"required,len=6"`
- }
- // VerifyNotifyEmail verifies code and adds email to notification list
- // POST /api/v1/user/notify-email/verify
- func (h *UserHandler) VerifyNotifyEmail(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- var req VerifyNotifyEmailRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- err := h.userService.VerifyAndAddNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Code, h.emailCache)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // Return updated user
- updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, profileResp)
- }
- // RemoveNotifyEmailRequest represents the request to remove a notify email
- type RemoveNotifyEmailRequest struct {
- Email string `json:"email" binding:"required,email"`
- }
- // RemoveNotifyEmail removes email from notification list
- // DELETE /api/v1/user/notify-email
- func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- var req RemoveNotifyEmailRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- err := h.userService.RemoveNotifyEmail(c.Request.Context(), subject.UserID, req.Email)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- // Return updated user
- updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, profileResp)
- }
- // ToggleNotifyEmailRequest represents the request to toggle a notify email's disabled state
- type ToggleNotifyEmailRequest struct {
- Email string `json:"email" binding:"required,email"`
- Disabled bool `json:"disabled"`
- }
- // ToggleNotifyEmail toggles the disabled state of a notification email
- // PUT /api/v1/user/notify-email/toggle
- func (h *UserHandler) ToggleNotifyEmail(c *gin.Context) {
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- response.Unauthorized(c, "User not authenticated")
- return
- }
- var req ToggleNotifyEmailRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- err := h.userService.ToggleNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Disabled)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- profileResp, err := h.buildUserProfileResponse(c.Request.Context(), subject.UserID, updatedUser)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, profileResp)
- }
- func (h *UserHandler) buildUserProfileResponse(ctx context.Context, userID int64, user *service.User) (userProfileResponse, error) {
- identities, err := h.userService.GetProfileIdentitySummaries(ctx, userID, user)
- if err != nil {
- return userProfileResponse{}, err
- }
- return userProfileResponseFromService(user, identities), nil
- }
- func userProfileResponseFromService(user *service.User, identities service.UserIdentitySummarySet) userProfileResponse {
- base := dto.UserFromService(user)
- if base == nil {
- return userProfileResponse{}
- }
- bindings := userProfileBindingMap(identities)
- profileSources, avatarSource, usernameSource := inferUserProfileSources(user, identities)
- return userProfileResponse{
- User: *base,
- AvatarURL: user.AvatarURL,
- AvatarSource: avatarSource,
- UsernameSource: usernameSource,
- DisplayNameSource: usernameSource,
- NicknameSource: usernameSource,
- ProfileSources: profileSources,
- Identities: identities,
- AuthBindings: bindings,
- IdentityBindings: bindings,
- EmailBound: identities.Email.Bound,
- LinuxDoBound: identities.LinuxDo.Bound,
- OIDCBound: identities.OIDC.Bound,
- WeChatBound: identities.WeChat.Bound,
- DingTalkBound: identities.DingTalk.Bound,
- }
- }
- func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string]service.UserIdentitySummary {
- return map[string]service.UserIdentitySummary{
- "email": identities.Email,
- "linuxdo": identities.LinuxDo,
- "oidc": identities.OIDC,
- "wechat": identities.WeChat,
- "dingtalk": identities.DingTalk,
- }
- }
- func inferUserProfileSources(user *service.User, identities service.UserIdentitySummarySet) (
- map[string]*userProfileSourceContext,
- *userProfileSourceContext,
- *userProfileSourceContext,
- ) {
- if user == nil {
- return nil, nil, nil
- }
- thirdParty := thirdPartyIdentityProviders(identities)
- var avatarSource *userProfileSourceContext
- avatarValue := strings.TrimSpace(user.AvatarURL)
- for _, summary := range thirdParty {
- if avatarValue != "" && avatarValue == strings.TrimSpace(summary.AvatarURL) {
- avatarSource = buildUserProfileSourceContext(summary.Provider)
- break
- }
- }
- usernameValue := strings.TrimSpace(user.Username)
- var usernameSource *userProfileSourceContext
- for _, summary := range thirdParty {
- if usernameValue != "" && usernameValue == strings.TrimSpace(summary.DisplayName) {
- usernameSource = buildUserProfileSourceContext(summary.Provider)
- break
- }
- }
- profileSources := map[string]*userProfileSourceContext{}
- if avatarSource != nil {
- profileSources["avatar"] = avatarSource
- }
- if usernameSource != nil {
- profileSources["username"] = usernameSource
- profileSources["display_name"] = usernameSource
- profileSources["nickname"] = usernameSource
- }
- if len(profileSources) == 0 {
- return nil, avatarSource, usernameSource
- }
- return profileSources, avatarSource, usernameSource
- }
- func thirdPartyIdentityProviders(identities service.UserIdentitySummarySet) []service.UserIdentitySummary {
- out := make([]service.UserIdentitySummary, 0, 3)
- for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat, identities.DingTalk} {
- if summary.Bound {
- out = append(out, summary)
- }
- }
- return out
- }
- func buildUserProfileSourceContext(provider string) *userProfileSourceContext {
- provider = strings.TrimSpace(provider)
- if provider == "" {
- return nil
- }
- return &userProfileSourceContext{
- Provider: provider,
- Source: provider,
- }
- }
|