diff --git a/backend/app/http/super/v1/creators.go b/backend/app/http/super/v1/creators.go index 9ca697b..7c690b2 100644 --- a/backend/app/http/super/v1/creators.go +++ b/backend/app/http/super/v1/creators.go @@ -2,8 +2,10 @@ package v1 import ( dto "quyun/v2/app/http/super/v1/dto" + v1_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" "github.com/gofiber/fiber/v3" ) @@ -26,3 +28,36 @@ type creators struct{} func (c *creators) List(ctx fiber.Ctx, filter *dto.TenantListFilter) (*requests.Pager, error) { return services.Super.ListTenants(ctx, filter) } + +// Get creator settings +// +// @Router /super/v1/creators/:tenantID/settings [get] +// @Summary Get creator settings +// @Description Get creator settings by tenant ID +// @Tags Creator +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Success 200 {object} v1_dto.Settings +// @Bind tenantID path +func (c *creators) GetSettings(ctx fiber.Ctx, tenantID int64) (*v1_dto.Settings, error) { + return services.Super.GetCreatorSettings(ctx, tenantID) +} + +// Update creator settings +// +// @Router /super/v1/creators/:tenantID/settings [put] +// @Summary Update creator settings +// @Description Update creator settings by tenant ID +// @Tags Creator +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param form body v1_dto.Settings true "Settings form" +// @Success 200 {string} string "Updated" +// @Bind user local key(__ctx_user) +// @Bind tenantID path +// @Bind form body +func (c *creators) UpdateSettings(ctx fiber.Ctx, user *models.User, tenantID int64, form *v1_dto.Settings) error { + return services.Super.UpdateCreatorSettings(ctx, user.ID, tenantID, form) +} diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index 91f2989..3b4f955 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -213,6 +213,16 @@ type UserItem struct { Balance int64 `json:"balance"` // BalanceFrozen 账户冻结余额(分)。 BalanceFrozen int64 `json:"balance_frozen"` + // Nickname 用户昵称(资料展示)。 + Nickname string `json:"nickname"` + // Avatar 用户头像URL(资料展示)。 + Avatar string `json:"avatar"` + // Gender 用户性别(male/female/secret)。 + Gender consts.Gender `json:"gender"` + // Bio 用户个人简介。 + Bio string `json:"bio"` + // IsRealNameVerified 是否已实名认证。 + IsRealNameVerified bool `json:"is_real_name_verified"` // OwnedTenantCount 拥有的租户数量。 OwnedTenantCount int64 `json:"owned_tenant_count"` // JoinedTenantCount 加入的租户数量。 diff --git a/backend/app/http/super/v1/dto/super_notification.go b/backend/app/http/super/v1/dto/super_notification.go index e506684..6690d2c 100644 --- a/backend/app/http/super/v1/dto/super_notification.go +++ b/backend/app/http/super/v1/dto/super_notification.go @@ -113,6 +113,22 @@ type SuperNotificationTemplateCreateForm struct { IsActive *bool `json:"is_active"` } +// SuperNotificationTemplateUpdateForm 超管通知模板更新参数。 +type SuperNotificationTemplateUpdateForm struct { + // TenantID 租户ID(不传代表不修改租户归属)。 + TenantID *int64 `json:"tenant_id"` + // Name 模板名称(可选)。 + Name *string `json:"name"` + // Type 通知类型(system/order/audit/interaction,可选)。 + Type *consts.NotificationType `json:"type"` + // Title 通知标题(可选)。 + Title *string `json:"title"` + // Content 通知内容(可选)。 + Content *string `json:"content"` + // IsActive 是否启用(可选)。 + IsActive *bool `json:"is_active"` +} + // SuperNotificationTemplateItem 超管通知模板条目。 type SuperNotificationTemplateItem struct { // ID 模板ID。 diff --git a/backend/app/http/super/v1/dto/super_payout.go b/backend/app/http/super/v1/dto/super_payout.go index 3dbca59..cadee71 100644 --- a/backend/app/http/super/v1/dto/super_payout.go +++ b/backend/app/http/super/v1/dto/super_payout.go @@ -72,3 +72,29 @@ type SuperPayoutAccountReviewForm struct { // Reason 审核说明(驳回时必填)。 Reason string `json:"reason"` } + +// SuperPayoutAccountCreateForm 超管创建结算账户参数。 +type SuperPayoutAccountCreateForm struct { + // UserID 收款账户归属用户ID。 + UserID int64 `json:"user_id" validate:"required"` + // Type 账户类型(bank/alipay)。 + Type string `json:"type" validate:"required,oneof=bank alipay"` + // Name 账户名称/开户行。 + Name string `json:"name" validate:"required,max=128"` + // Account 收款账号。 + Account string `json:"account" validate:"required,max=128"` + // Realname 收款人姓名。 + Realname string `json:"realname" validate:"required,max=128"` +} + +// SuperPayoutAccountUpdateForm 超管更新结算账户参数。 +type SuperPayoutAccountUpdateForm struct { + // Type 账户类型(bank/alipay,可选)。 + Type *string `json:"type" validate:"omitempty,oneof=bank alipay"` + // Name 账户名称/开户行(可选)。 + Name *string `json:"name" validate:"omitempty,max=128"` + // Account 收款账号(可选)。 + Account *string `json:"account" validate:"omitempty,max=128"` + // Realname 收款人姓名(可选)。 + Realname *string `json:"realname" validate:"omitempty,max=128"` +} diff --git a/backend/app/http/super/v1/dto/super_user.go b/backend/app/http/super/v1/dto/super_user.go index 87c5778..8f83181 100644 --- a/backend/app/http/super/v1/dto/super_user.go +++ b/backend/app/http/super/v1/dto/super_user.go @@ -117,6 +117,24 @@ type SuperUserRealNameResponse struct { IDCardMasked string `json:"id_card_masked"` } +// SuperUserProfileUpdateForm 超管用户资料更新表单。 +type SuperUserProfileUpdateForm struct { + // Nickname 昵称(可选,空字符串表示清空)。 + Nickname *string `json:"nickname"` + // Avatar 头像URL(可选,空字符串表示清空)。 + Avatar *string `json:"avatar"` + // Gender 性别(可选)。 + Gender *consts.Gender `json:"gender"` + // Bio 个人简介(可选,空字符串表示清空)。 + Bio *string `json:"bio"` + // IsRealNameVerified 是否已实名认证(可选)。 + IsRealNameVerified *bool `json:"is_real_name_verified"` + // RealName 真实姓名(可选,用于更新实名认证信息)。 + RealName *string `json:"real_name"` + // IDCard 身份证号(可选,用于更新实名认证信息)。 + IDCard *string `json:"id_card"` +} + // SuperUserContentActionListFilter 超管用户互动内容列表过滤条件。 type SuperUserContentActionListFilter struct { requests.Pagination diff --git a/backend/app/http/super/v1/notifications.go b/backend/app/http/super/v1/notifications.go index 05f2f4b..5d17fb9 100644 --- a/backend/app/http/super/v1/notifications.go +++ b/backend/app/http/super/v1/notifications.go @@ -4,6 +4,7 @@ import ( dto "quyun/v2/app/http/super/v1/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" "github.com/gofiber/fiber/v3" ) @@ -70,3 +71,21 @@ func (c *notifications) ListTemplates(ctx fiber.Ctx, filter *dto.SuperNotificati func (c *notifications) CreateTemplate(ctx fiber.Ctx, form *dto.SuperNotificationTemplateCreateForm) (*dto.SuperNotificationTemplateItem, error) { return services.Super.CreateNotificationTemplate(ctx, form) } + +// Update notification template +// +// @Router /super/v1/notifications/templates/:id [patch] +// @Summary Update notification template +// @Description Update notification template +// @Tags Notification +// @Accept json +// @Produce json +// @Param id path int64 true "Template ID" +// @Param form body dto.SuperNotificationTemplateUpdateForm true "Update form" +// @Success 200 {object} dto.SuperNotificationTemplateItem +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *notifications) UpdateTemplate(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperNotificationTemplateUpdateForm) (*dto.SuperNotificationTemplateItem, error) { + return services.Super.UpdateNotificationTemplate(ctx, user.ID, id, form) +} diff --git a/backend/app/http/super/v1/payout_accounts.go b/backend/app/http/super/v1/payout_accounts.go index 00b3529..bb54535 100644 --- a/backend/app/http/super/v1/payout_accounts.go +++ b/backend/app/http/super/v1/payout_accounts.go @@ -28,6 +28,24 @@ func (c *payoutAccounts) List(ctx fiber.Ctx, filter *dto.SuperPayoutAccountListF return services.Super.ListPayoutAccounts(ctx, filter) } +// Create payout account +// +// @Router /super/v1/creators/:tenantID/payout-accounts [post] +// @Summary Create payout account +// @Description Create payout account for tenant +// @Tags Finance +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param form body dto.SuperPayoutAccountCreateForm true "Create form" +// @Success 200 {string} string "Created" +// @Bind user local key(__ctx_user) +// @Bind tenantID path +// @Bind form body +func (c *payoutAccounts) Create(ctx fiber.Ctx, user *models.User, tenantID int64, form *dto.SuperPayoutAccountCreateForm) error { + return services.Super.CreatePayoutAccount(ctx, user.ID, tenantID, form) +} + // Remove payout account // // @Router /super/v1/payout-accounts/:id [delete] @@ -61,3 +79,21 @@ func (c *payoutAccounts) Remove(ctx fiber.Ctx, user *models.User, id int64) erro func (c *payoutAccounts) Review(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperPayoutAccountReviewForm) error { return services.Super.ReviewPayoutAccount(ctx, user.ID, id, form) } + +// Update payout account +// +// @Router /super/v1/payout-accounts/:id [patch] +// @Summary Update payout account +// @Description Update payout account across tenants +// @Tags Finance +// @Accept json +// @Produce json +// @Param id path int64 true "Payout account ID" +// @Param form body dto.SuperPayoutAccountUpdateForm true "Update form" +// @Success 200 {string} string "Updated" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *payoutAccounts) Update(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperPayoutAccountUpdateForm) error { + return services.Super.UpdatePayoutAccount(ctx, user.ID, id, form) +} diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 0f80dcd..e01baab 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -228,6 +228,18 @@ func (r *Routes) Register(router fiber.Router) { r.creators.List, Query[dto.TenantListFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/creators/:tenantID/settings -> creators.GetSettings") + router.Get("/super/v1/creators/:tenantID/settings"[len(r.Path()):], DataFunc1( + r.creators.GetSettings, + PathParam[int64]("tenantID"), + )) + r.log.Debugf("Registering route: Put /super/v1/creators/:tenantID/settings -> creators.UpdateSettings") + router.Put("/super/v1/creators/:tenantID/settings"[len(r.Path()):], Func3( + r.creators.UpdateSettings, + Local[*models.User]("__ctx_user"), + PathParam[int64]("tenantID"), + Body[v1_dto.Settings]("form"), + )) // Register routes for controller: finance r.log.Debugf("Registering route: Get /super/v1/finance/anomalies/balances -> finance.ListBalanceAnomalies") router.Get("/super/v1/finance/anomalies/balances"[len(r.Path()):], DataFunc1( @@ -261,6 +273,13 @@ func (r *Routes) Register(router fiber.Router) { r.notifications.ListTemplates, Query[dto.SuperNotificationTemplateListFilter]("filter"), )) + r.log.Debugf("Registering route: Patch /super/v1/notifications/templates/:id -> notifications.UpdateTemplate") + router.Patch("/super/v1/notifications/templates/:id"[len(r.Path()):], DataFunc3( + r.notifications.UpdateTemplate, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperNotificationTemplateUpdateForm]("form"), + )) r.log.Debugf("Registering route: Post /super/v1/notifications/broadcast -> notifications.Broadcast") router.Post("/super/v1/notifications/broadcast"[len(r.Path()):], Func1( r.notifications.Broadcast, @@ -318,6 +337,20 @@ func (r *Routes) Register(router fiber.Router) { r.payoutAccounts.List, Query[dto.SuperPayoutAccountListFilter]("filter"), )) + r.log.Debugf("Registering route: Patch /super/v1/payout-accounts/:id -> payoutAccounts.Update") + router.Patch("/super/v1/payout-accounts/:id"[len(r.Path()):], Func3( + r.payoutAccounts.Update, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperPayoutAccountUpdateForm]("form"), + )) + r.log.Debugf("Registering route: Post /super/v1/creators/:tenantID/payout-accounts -> payoutAccounts.Create") + router.Post("/super/v1/creators/:tenantID/payout-accounts"[len(r.Path()):], Func3( + r.payoutAccounts.Create, + Local[*models.User]("__ctx_user"), + PathParam[int64]("tenantID"), + Body[dto.SuperPayoutAccountCreateForm]("form"), + )) r.log.Debugf("Registering route: Post /super/v1/payout-accounts/:id/review -> payoutAccounts.Review") router.Post("/super/v1/payout-accounts/:id/review"[len(r.Path()):], Func3( r.payoutAccounts.Review, @@ -487,6 +520,13 @@ func (r *Routes) Register(router fiber.Router) { router.Get("/super/v1/users/statuses"[len(r.Path()):], DataFunc0( r.users.Statuses, )) + r.log.Debugf("Registering route: Patch /super/v1/users/:id -> users.UpdateProfile") + router.Patch("/super/v1/users/:id"[len(r.Path()):], Func3( + r.users.UpdateProfile, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperUserProfileUpdateForm]("form"), + )) r.log.Debugf("Registering route: Patch /super/v1/users/:id/roles -> users.UpdateRoles") router.Patch("/super/v1/users/:id/roles"[len(r.Path()):], Func2( r.users.UpdateRoles, diff --git a/backend/app/http/super/v1/users.go b/backend/app/http/super/v1/users.go index b1da93c..9047131 100644 --- a/backend/app/http/super/v1/users.go +++ b/backend/app/http/super/v1/users.go @@ -4,6 +4,7 @@ import ( dto "quyun/v2/app/http/super/v1/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" "github.com/gofiber/fiber/v3" ) @@ -233,6 +234,24 @@ func (c *users) UpdateRoles(ctx fiber.Ctx, id int64, form *dto.UserRolesUpdateFo return services.Super.UpdateUserRoles(ctx, id, form) } +// Update user profile +// +// @Router /super/v1/users/:id [patch] +// @Summary Update user profile +// @Description Update user profile fields +// @Tags User +// @Accept json +// @Produce json +// @Param id path int64 true "User ID" +// @Param form body dto.SuperUserProfileUpdateForm true "Update form" +// @Success 200 {string} string "Updated" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *users) UpdateProfile(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperUserProfileUpdateForm) error { + return services.Super.UpdateUserProfile(ctx, user.ID, id, form) +} + // User statistics // // @Router /super/v1/users/statistics [get] diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 37211c2..41f2b88 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -261,10 +261,15 @@ func (s *super) ListUsers(ctx context.Context, filter *super_dto.UserListFilter) CreatedAt: s.formatTime(u.CreatedAt), UpdatedAt: s.formatTime(u.UpdatedAt), }, - Balance: u.Balance, - BalanceFrozen: u.BalanceFrozen, - OwnedTenantCount: ownedCountMap[u.ID], - JoinedTenantCount: joinedCountMap[u.ID], + Balance: u.Balance, + BalanceFrozen: u.BalanceFrozen, + Nickname: u.Nickname, + Avatar: u.Avatar, + Gender: u.Gender, + Bio: u.Bio, + IsRealNameVerified: u.IsRealNameVerified, + OwnedTenantCount: ownedCountMap[u.ID], + JoinedTenantCount: joinedCountMap[u.ID], }) } @@ -295,8 +300,13 @@ func (s *super) GetUser(ctx context.Context, id int64) (*super_dto.UserItem, err CreatedAt: u.CreatedAt.Format(time.RFC3339), UpdatedAt: u.UpdatedAt.Format(time.RFC3339), }, - Balance: u.Balance, - BalanceFrozen: u.BalanceFrozen, + Balance: u.Balance, + BalanceFrozen: u.BalanceFrozen, + Nickname: u.Nickname, + Avatar: u.Avatar, + Gender: u.Gender, + Bio: u.Bio, + IsRealNameVerified: u.IsRealNameVerified, }, nil } @@ -705,6 +715,84 @@ func (s *super) UpdateUserRoles(ctx context.Context, id int64, form *super_dto.U return nil } +func (s *super) UpdateUserProfile(ctx context.Context, operatorID, userID int64, form *super_dto.SuperUserProfileUpdateForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if userID == 0 || form == nil { + return errorx.ErrBadRequest.WithMsg("更新参数不能为空") + } + if form.Gender != nil && !form.Gender.IsValid() { + return errorx.ErrBadRequest.WithMsg("性别非法") + } + + tbl, q := models.UserQuery.QueryContext(ctx) + user, err := q.Where(tbl.ID.Eq(userID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound + } + return errorx.ErrDatabaseError.WithCause(err) + } + + updates := make(map[string]any) + if form.Nickname != nil { + updates["nickname"] = strings.TrimSpace(*form.Nickname) + } + if form.Avatar != nil { + updates["avatar"] = strings.TrimSpace(*form.Avatar) + } + if form.Gender != nil { + updates["gender"] = *form.Gender + } + if form.Bio != nil { + updates["bio"] = strings.TrimSpace(*form.Bio) + } + if form.IsRealNameVerified != nil { + updates["is_real_name_verified"] = *form.IsRealNameVerified + if *form.IsRealNameVerified { + updates["verified_at"] = time.Now() + } else { + updates["verified_at"] = time.Time{} + } + } + + if form.RealName != nil || form.IDCard != nil { + // 更新实名信息时保持原有元数据,避免覆盖其它字段。 + metaMap := make(map[string]any) + if len(user.Metas) > 0 { + _ = json.Unmarshal(user.Metas, &metaMap) + } + if form.RealName != nil { + metaMap["real_name"] = strings.TrimSpace(*form.RealName) + } + if form.IDCard != nil { + idCard := strings.TrimSpace(*form.IDCard) + if idCard != "" { + metaMap["id_card"] = "ENC:" + idCard + } else { + metaMap["id_card"] = "" + } + } + raw, _ := json.Marshal(metaMap) + updates["metas"] = types.JSON(raw) + } + + if len(updates) == 0 { + return errorx.ErrBadRequest.WithMsg("没有可更新的字段") + } + + if _, err := q.Where(tbl.ID.Eq(userID)).Updates(updates); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + if Audit != nil { + Audit.Log(ctx, 0, operatorID, "update_user_profile", cast.ToString(userID), "Update user profile") + } + + return nil +} + func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.TenantListFilter{} @@ -1333,25 +1421,114 @@ func (s *super) ReviewCreatorApplication(ctx context.Context, operatorID, tenant return nil } -func (s *super) CreateTenant(ctx context.Context, form *super_dto.TenantCreateForm) error { - uid := form.AdminUserID - if _, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First(); err != nil { - return errorx.ErrRecordNotFound.WithMsg("用户不存在") +func (s *super) GetCreatorSettings(ctx context.Context, tenantID int64) (*v1_dto.Settings, error) { + if tenantID == 0 { + return nil, errorx.ErrBadRequest.WithMsg("租户ID不能为空") } - t := &models.Tenant{ - UserID: uid, - Name: form.Name, - Code: form.Code, - UUID: types.UUID(uuid.New()), - Status: consts.TenantStatusVerified, + t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tenantID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound + } + return nil, errorx.ErrDatabaseError.WithCause(err) } - if err := models.TenantQuery.WithContext(ctx).Create(t); err != nil { + + cfg := t.Config.Data() + return &v1_dto.Settings{ + ID: t.ID, + Name: t.Name, + Bio: cfg.Bio, + Avatar: cfg.Avatar, + Cover: cfg.Cover, + Description: cfg.Description, + }, nil +} + +func (s *super) UpdateCreatorSettings(ctx context.Context, operatorID, tenantID int64, form *v1_dto.Settings) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if tenantID == 0 || form == nil { + return errorx.ErrBadRequest.WithMsg("更新参数不能为空") + } + + t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tenantID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound + } return errorx.ErrDatabaseError.WithCause(err) } + + // 超管可直接更新租户设置与展示信息。 + cfg := t.Config.Data() + cfg.Bio = form.Bio + cfg.Avatar = form.Avatar + cfg.Cover = form.Cover + cfg.Description = form.Description + + _, err = models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tenantID)).Updates(&models.Tenant{ + Name: form.Name, + Config: types.NewJSONType(cfg), + }) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + if Audit != nil { + Audit.Log(ctx, tenantID, operatorID, "update_creator_settings", cast.ToString(tenantID), "Update creator settings") + } + return nil } +func (s *super) CreateTenant(ctx context.Context, form *super_dto.TenantCreateForm) error { + if form == nil { + return errorx.ErrBadRequest.WithMsg("创建参数不能为空") + } + if form.Duration <= 0 { + return errorx.ErrBadRequest.WithMsg("租户有效期不能为空") + } + uid := form.AdminUserID + + expiredAt := time.Now().AddDate(0, 0, form.Duration) + return models.Q.Transaction(func(tx *models.Query) error { + // 校验管理员用户存在,避免创建脏数据。 + if _, err := tx.User.WithContext(ctx).Where(tx.User.ID.Eq(uid)).First(); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("用户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + t := &models.Tenant{ + UserID: uid, + Name: form.Name, + Code: form.Code, + UUID: types.UUID(uuid.New()), + Status: consts.TenantStatusVerified, + ExpiredAt: expiredAt, + } + if err := tx.Tenant.WithContext(ctx).Create(t); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + // 同步写入管理员成员关系,保证租户成员视角一致。 + tu := &models.TenantUser{ + TenantID: t.ID, + UserID: uid, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleTenantAdmin}, + Status: consts.UserStatusVerified, + } + if err := tx.TenantUser.WithContext(ctx).Create(tu); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + return nil + }) +} + func (s *super) GetTenant(ctx context.Context, id int64) (*super_dto.TenantItem, error) { tbl, q := models.TenantQuery.QueryContext(ctx) t, err := q.Where(tbl.ID.Eq(id)).First() @@ -1611,6 +1788,123 @@ func (s *super) ListPayoutAccounts(ctx context.Context, filter *super_dto.SuperP }, nil } +func (s *super) CreatePayoutAccount(ctx context.Context, operatorID, tenantID int64, form *super_dto.SuperPayoutAccountCreateForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if tenantID == 0 || form == nil { + return errorx.ErrBadRequest.WithMsg("创建参数不能为空") + } + if form.UserID == 0 { + return errorx.ErrBadRequest.WithMsg("用户ID不能为空") + } + + accountType := strings.TrimSpace(form.Type) + if accountType == "" { + return errorx.ErrBadRequest.WithMsg("账户类型不能为空") + } + if !consts.PayoutAccountType(accountType).IsValid() { + return errorx.ErrBadRequest.WithMsg("账户类型非法") + } + + // 校验租户与用户存在,避免关联脏数据。 + if _, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tenantID)).First(); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if _, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(form.UserID)).First(); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("用户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + pa := &models.PayoutAccount{ + TenantID: tenantID, + UserID: form.UserID, + Type: consts.PayoutAccountType(accountType), + Name: strings.TrimSpace(form.Name), + Account: strings.TrimSpace(form.Account), + Realname: strings.TrimSpace(form.Realname), + Status: consts.PayoutAccountStatusPending, + } + if err := models.PayoutAccountQuery.WithContext(ctx).Create(pa); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + if Audit != nil { + Audit.Log(ctx, tenantID, operatorID, "create_payout_account", cast.ToString(pa.ID), "Create payout account") + } + + return nil +} + +func (s *super) UpdatePayoutAccount(ctx context.Context, operatorID, id int64, form *super_dto.SuperPayoutAccountUpdateForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if id == 0 || form == nil { + return errorx.ErrBadRequest.WithMsg("更新参数不能为空") + } + + tbl, q := models.PayoutAccountQuery.QueryContext(ctx) + account, err := q.Where(tbl.ID.Eq(id)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("结算账户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + updates := make(map[string]any) + changed := false + if form.Type != nil { + typ := strings.TrimSpace(*form.Type) + if typ == "" { + return errorx.ErrBadRequest.WithMsg("账户类型不能为空") + } + if !consts.PayoutAccountType(typ).IsValid() { + return errorx.ErrBadRequest.WithMsg("账户类型非法") + } + updates["type"] = consts.PayoutAccountType(typ) + changed = true + } + if form.Name != nil { + updates["name"] = strings.TrimSpace(*form.Name) + changed = true + } + if form.Account != nil { + updates["account"] = strings.TrimSpace(*form.Account) + changed = true + } + if form.Realname != nil { + updates["realname"] = strings.TrimSpace(*form.Realname) + changed = true + } + if len(updates) == 0 { + return errorx.ErrBadRequest.WithMsg("没有可更新的字段") + } + + if changed && account.Status == consts.PayoutAccountStatusApproved { + updates["status"] = consts.PayoutAccountStatusPending + updates["reviewed_by"] = int64(0) + updates["reviewed_at"] = time.Time{} + updates["review_reason"] = "" + } + + if _, err := q.Where(tbl.ID.Eq(id)).Updates(updates); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + if Audit != nil { + Audit.Log(ctx, account.TenantID, operatorID, "update_payout_account", cast.ToString(id), "Update payout account") + } + + return nil +} + func (s *super) RemovePayoutAccount(ctx context.Context, operatorID, id int64) error { if operatorID == 0 { return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") @@ -4716,6 +5010,112 @@ func (s *super) CreateNotificationTemplate(ctx context.Context, form *super_dto. return item, nil } +func (s *super) UpdateNotificationTemplate(ctx context.Context, operatorID, id int64, form *super_dto.SuperNotificationTemplateUpdateForm) (*super_dto.SuperNotificationTemplateItem, error) { + if operatorID == 0 { + return nil, errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if id == 0 || form == nil { + return nil, errorx.ErrBadRequest.WithMsg("模板参数不能为空") + } + + tbl, q := models.NotificationTemplateQuery.QueryContext(ctx) + if _, err := q.Where(tbl.ID.Eq(id)).First(); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("模板不存在") + } + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + updates := make(map[string]any) + if form.TenantID != nil { + if *form.TenantID < 0 { + return nil, errorx.ErrBadRequest.WithMsg("租户ID无效") + } + if *form.TenantID > 0 { + // 校验租户存在,避免模板指向无效租户。 + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + if _, err := tenantQuery.Where(tenantTbl.ID.Eq(*form.TenantID)).First(); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + return nil, errorx.ErrDatabaseError.WithCause(err) + } + } + updates["tenant_id"] = *form.TenantID + } + if form.Name != nil { + name := strings.TrimSpace(*form.Name) + if name == "" { + return nil, errorx.ErrBadRequest.WithMsg("模板名称不能为空") + } + updates["name"] = name + } + if form.Type != nil { + if !form.Type.IsValid() { + return nil, errorx.ErrBadRequest.WithMsg("通知类型非法") + } + updates["type"] = *form.Type + } + if form.Title != nil { + title := strings.TrimSpace(*form.Title) + if title == "" { + return nil, errorx.ErrBadRequest.WithMsg("模板标题不能为空") + } + updates["title"] = title + } + if form.Content != nil { + content := strings.TrimSpace(*form.Content) + if content == "" { + return nil, errorx.ErrBadRequest.WithMsg("模板内容不能为空") + } + updates["content"] = content + } + if form.IsActive != nil { + updates["is_active"] = *form.IsActive + } + if len(updates) == 0 { + return nil, errorx.ErrBadRequest.WithMsg("没有可更新的字段") + } + + if _, err := q.Where(tbl.ID.Eq(id)).Updates(updates); err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + updated, err := q.Where(tbl.ID.Eq(id)).First() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + item := &super_dto.SuperNotificationTemplateItem{ + ID: updated.ID, + TenantID: updated.TenantID, + Name: updated.Name, + Type: updated.Type, + Title: updated.Title, + Content: updated.Content, + IsActive: updated.IsActive, + CreatedAt: s.formatTime(updated.CreatedAt), + UpdatedAt: s.formatTime(updated.UpdatedAt), + } + if updated.TenantID > 0 { + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenant, err := tenantQuery.Where(tenantTbl.ID.Eq(updated.TenantID)).First() + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + } + + if Audit != nil { + Audit.Log(ctx, updated.TenantID, operatorID, "update_notification_template", cast.ToString(id), "Update notification template") + } + + return item, nil +} + func (s *super) ListAuditLogs(ctx context.Context, filter *super_dto.SuperAuditLogListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.SuperAuditLogListFilter{} diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 181dd4c..1e0462e 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -2,6 +2,7 @@ package services import ( "database/sql" + "encoding/json" "errors" "testing" "time" @@ -9,8 +10,10 @@ import ( "quyun/v2/app/commands/testx" "quyun/v2/app/errorx" super_dto "quyun/v2/app/http/super/v1/dto" + v1_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/database" + "quyun/v2/database/fields" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -140,10 +143,12 @@ func (s *SuperTestSuite) Test_CreateTenant() { models.UserQuery.WithContext(ctx).Create(u) Convey("should create tenant", func() { + startAt := time.Now() form := &super_dto.TenantCreateForm{ Name: "Super Tenant", Code: "st1", AdminUserID: u.ID, + Duration: 7, } err := Super.CreateTenant(ctx, form) So(err, ShouldBeNil) @@ -153,6 +158,13 @@ func (s *SuperTestSuite) Test_CreateTenant() { So(t.Name, ShouldEqual, "Super Tenant") So(t.UserID, ShouldEqual, u.ID) So(t.Status, ShouldEqual, consts.TenantStatusVerified) + So(t.ExpiredAt.After(startAt), ShouldBeTrue) + + tu, _ := models.TenantUserQuery.WithContext(ctx). + Where(models.TenantUserQuery.TenantID.Eq(t.ID), models.TenantUserQuery.UserID.Eq(u.ID)). + First() + So(tu, ShouldNotBeNil) + So(tu.Status, ShouldEqual, consts.UserStatusVerified) }) }) } @@ -1203,3 +1215,195 @@ func (s *SuperTestSuite) Test_PayoutAccountReview() { }) }) } + +func (s *SuperTestSuite) Test_UpdateUserProfile() { + Convey("UpdateUserProfile", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser) + + meta := types.JSON([]byte(`{"real_name":"Old Name","id_card":"ENC:1111"}`)) + u := &models.User{ + Username: "profile_user", + Nickname: "Old", + Avatar: "http://old-avatar", + Gender: consts.GenderSecret, + Bio: "old bio", + Metas: meta, + } + models.UserQuery.WithContext(ctx).Create(u) + + Convey("should update profile fields and real-name meta", func() { + form := &super_dto.SuperUserProfileUpdateForm{ + Nickname: lo.ToPtr("New Nick"), + Avatar: lo.ToPtr("http://new-avatar"), + Gender: lo.ToPtr(consts.GenderMale), + Bio: lo.ToPtr("new bio"), + IsRealNameVerified: lo.ToPtr(true), + RealName: lo.ToPtr("New Name"), + IDCard: lo.ToPtr("123456789012345678"), + } + err := Super.UpdateUserProfile(ctx, 1001, u.ID, form) + So(err, ShouldBeNil) + + updated, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(u.ID)).First() + So(err, ShouldBeNil) + So(updated.Nickname, ShouldEqual, "New Nick") + So(updated.Avatar, ShouldEqual, "http://new-avatar") + So(updated.Gender, ShouldEqual, consts.GenderMale) + So(updated.Bio, ShouldEqual, "new bio") + So(updated.IsRealNameVerified, ShouldBeTrue) + So(updated.VerifiedAt.IsZero(), ShouldBeFalse) + + metaMap := make(map[string]interface{}) + So(json.Unmarshal(updated.Metas, &metaMap), ShouldBeNil) + So(metaMap["real_name"], ShouldEqual, "New Name") + So(metaMap["id_card"], ShouldEqual, "ENC:123456789012345678") + }) + }) +} + +func (s *SuperTestSuite) Test_UpdateCreatorSettings() { + Convey("UpdateCreatorSettings", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNameUser) + + owner := &models.User{Username: "settings_owner"} + models.UserQuery.WithContext(ctx).Create(owner) + + tenant := &models.Tenant{ + UserID: owner.ID, + Name: "Old Tenant", + Code: "creator-settings", + Status: consts.TenantStatusVerified, + Config: types.NewJSONType(fields.TenantConfig{}), + } + models.TenantQuery.WithContext(ctx).Create(tenant) + + form := &v1_dto.Settings{ + Name: "New Tenant", + Bio: "new bio", + Avatar: "http://avatar", + Cover: "http://cover", + Description: "new description", + } + err := Super.UpdateCreatorSettings(ctx, 2001, tenant.ID, form) + So(err, ShouldBeNil) + + updated, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tenant.ID)).First() + So(err, ShouldBeNil) + So(updated.Name, ShouldEqual, "New Tenant") + cfg := updated.Config.Data() + So(cfg.Bio, ShouldEqual, "new bio") + So(cfg.Avatar, ShouldEqual, "http://avatar") + So(cfg.Cover, ShouldEqual, "http://cover") + So(cfg.Description, ShouldEqual, "new description") + }) +} + +func (s *SuperTestSuite) Test_PayoutAccountCreateUpdate() { + Convey("PayoutAccountCreateUpdate", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNamePayoutAccount, models.TableNameUser, models.TableNameTenant) + + admin := &models.User{Username: "payout_admin_2"} + owner := &models.User{Username: "payout_owner_2"} + models.UserQuery.WithContext(ctx).Create(admin, owner) + + tenant := &models.Tenant{ + UserID: owner.ID, + Name: "Payout Tenant 2", + Code: "payout-2", + Status: consts.TenantStatusVerified, + } + models.TenantQuery.WithContext(ctx).Create(tenant) + + Convey("should create payout account", func() { + err := Super.CreatePayoutAccount(ctx, admin.ID, tenant.ID, &super_dto.SuperPayoutAccountCreateForm{ + UserID: owner.ID, + Type: "bank", + Name: "Bank", + Account: "123", + Realname: "Owner", + }) + So(err, ShouldBeNil) + + account, err := models.PayoutAccountQuery.WithContext(ctx). + Where(models.PayoutAccountQuery.TenantID.Eq(tenant.ID), models.PayoutAccountQuery.UserID.Eq(owner.ID)). + First() + So(err, ShouldBeNil) + So(account.Status, ShouldEqual, consts.PayoutAccountStatusPending) + }) + + Convey("should reset status when updating approved account", func() { + account := &models.PayoutAccount{ + TenantID: tenant.ID, + UserID: owner.ID, + Type: consts.PayoutAccountTypeBank, + Name: "Bank", + Account: "111", + Realname: "Owner", + Status: consts.PayoutAccountStatusApproved, + ReviewedBy: admin.ID, + ReviewReason: "ok", + ReviewedAt: time.Now(), + } + models.PayoutAccountQuery.WithContext(ctx).Create(account) + + err := Super.UpdatePayoutAccount(ctx, admin.ID, account.ID, &super_dto.SuperPayoutAccountUpdateForm{ + Account: lo.ToPtr("222"), + }) + So(err, ShouldBeNil) + + updated, err := models.PayoutAccountQuery.WithContext(ctx).Where(models.PayoutAccountQuery.ID.Eq(account.ID)).First() + So(err, ShouldBeNil) + So(updated.Account, ShouldEqual, "222") + So(updated.Status, ShouldEqual, consts.PayoutAccountStatusPending) + So(updated.ReviewedBy, ShouldEqual, int64(0)) + So(updated.ReviewReason, ShouldEqual, "") + }) + }) +} + +func (s *SuperTestSuite) Test_UpdateNotificationTemplate() { + Convey("UpdateNotificationTemplate", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameNotificationTemplate, models.TableNameTenant, models.TableNameUser) + + owner := &models.User{Username: "tmpl_owner"} + models.UserQuery.WithContext(ctx).Create(owner) + + tenant := &models.Tenant{ + UserID: owner.ID, + Name: "Template Tenant", + Code: "tmpl", + Status: consts.TenantStatusVerified, + } + models.TenantQuery.WithContext(ctx).Create(tenant) + + tmpl := &models.NotificationTemplate{ + TenantID: tenant.ID, + Name: "Old Template", + Type: consts.NotificationTypeSystem, + Title: "Old Title", + Content: "Old Content", + IsActive: true, + } + models.NotificationTemplateQuery.WithContext(ctx).Create(tmpl) + + item, err := Super.UpdateNotificationTemplate(ctx, 3001, tmpl.ID, &super_dto.SuperNotificationTemplateUpdateForm{ + Name: lo.ToPtr("New Template"), + Title: lo.ToPtr("New Title"), + Content: lo.ToPtr("New Content"), + IsActive: lo.ToPtr(false), + }) + So(err, ShouldBeNil) + So(item.Name, ShouldEqual, "New Template") + So(item.IsActive, ShouldBeFalse) + + updated, err := models.NotificationTemplateQuery.WithContext(ctx).Where(models.NotificationTemplateQuery.ID.Eq(tmpl.ID)).First() + So(err, ShouldBeNil) + So(updated.Title, ShouldEqual, "New Title") + So(updated.Content, ShouldEqual, "New Content") + So(updated.IsActive, ShouldBeFalse) + }) +} diff --git a/backend/config.minio.toml b/backend/config.minio.toml new file mode 100644 index 0000000..ef27e1d --- /dev/null +++ b/backend/config.minio.toml @@ -0,0 +1,81 @@ +# MinIO configuration for local S3-compatible storage. +# Use with: ENV_LOCAL=minio + +[App] +Mode = "testing" +BaseURI = "http://localhost:8080" + +[App.Super] +Token = "" + +[Http] +Port = 18080 + +[Http.Cors] +Mode = "dev" + +[[Http.Cors.Whitelist]] +AllowOrigin = "http://localhost:5173" +AllowHeaders = "Content-Type,Authorization" +AllowMethods = "GET,POST,PUT,PATCH,DELETE,OPTIONS" +ExposeHeaders = "*" +AllowCredentials = true + +[Http.RateLimit] +Enabled = true +Max = 5 +WindowSeconds = 10 +Message = "Too Many Requests" + +[Http.RateLimit.Redis] +Addrs = ["127.0.0.1:6379"] +Username = "" +Password = "testpass" +DB = 2 +Prefix = "rl:" + +[Database] +Host = "10.1.1.2" +Port = 5433 +Database = "quyun_v2" +Username = "postgres" +Password = "xixi0202" +SslMode = "disable" +TimeZone = "Asia/Shanghai" +MaxIdleConns = 10 +MaxOpenConns = 100 +ConnMaxLifetime = "1800s" +ConnMaxIdleTime = "300s" + +[JWT] +SigningKey = "test-secret" +ExpiresTime = "168h" +Issuer = "v2" + +[HashIDs] +Salt = "test-salt" +MinLength = 8 + +[Redis] +Host = "127.0.0.1" +Port = 6379 +Password = "" +DB = 0 +PoolSize = 20 +MinIdleConns = 5 +MaxRetries = 3 +DialTimeout = "5s" +ReadTimeout = "3s" +WriteTimeout = "3s" + +[Storage] +Type = "s3" +LocalPath = "./storage" +Secret = "test-storage-secret" +BaseURL = "/v1/storage" +AccessKey = "minioadmin" +SecretKey = "minioadmin" +Region = "us-east-1" +Bucket = "quyun-assets" +Endpoint = "http://127.0.0.1:9000" +PathStyle = true diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 4e1a684..d66bd4f 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -997,6 +997,121 @@ const docTemplate = `{ } } }, + "/super/v1/creators/{tenantID}/payout-accounts": { + "post": { + "description": "Create payout account for tenant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Create payout account", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "description": "Create form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperPayoutAccountCreateForm" + } + } + ], + "responses": { + "200": { + "description": "Created", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/creators/{tenantID}/settings": { + "get": { + "description": "Get creator settings by tenant ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Creator" + ], + "summary": "Get creator settings", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "tenantID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.Settings" + } + } + } + }, + "put": { + "description": "Update creator settings by tenant ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Creator" + ], + "summary": "Update creator settings", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "description": "Settings form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.Settings" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/finance/anomalies/balances": { "get": { "description": "List balance anomalies across users", @@ -1358,6 +1473,48 @@ const docTemplate = `{ } } }, + "/super/v1/notifications/templates/{id}": { + "patch": { + "description": "Update notification template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Update notification template", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateItem" + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -1675,6 +1832,46 @@ const docTemplate = `{ } } } + }, + "patch": { + "description": "Update payout account across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Update payout account", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Payout account ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperPayoutAccountUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } } }, "/super/v1/payout-accounts/{id}/review": { @@ -2813,6 +3010,46 @@ const docTemplate = `{ } } } + }, + "patch": { + "description": "Update user profile fields", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Update user profile", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperUserProfileUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } } }, "/super/v1/users/{id}/coupons": { @@ -9099,6 +9336,39 @@ const docTemplate = `{ } } }, + "dto.SuperNotificationTemplateUpdateForm": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容(可选)。", + "type": "string" + }, + "is_active": { + "description": "IsActive 是否启用(可选)。", + "type": "boolean" + }, + "name": { + "description": "Name 模板名称(可选)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(不传代表不修改租户归属)。", + "type": "integer" + }, + "title": { + "description": "Title 通知标题(可选)。", + "type": "string" + }, + "type": { + "description": "Type 通知类型(system/order/audit/interaction,可选)。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + } + } + }, "dto.SuperOrderAnomalyItem": { "type": "object", "properties": { @@ -9383,6 +9653,45 @@ const docTemplate = `{ } } }, + "dto.SuperPayoutAccountCreateForm": { + "type": "object", + "required": [ + "account", + "name", + "realname", + "type", + "user_id" + ], + "properties": { + "account": { + "description": "Account 收款账号。", + "type": "string", + "maxLength": 128 + }, + "name": { + "description": "Name 账户名称/开户行。", + "type": "string", + "maxLength": 128 + }, + "realname": { + "description": "Realname 收款人姓名。", + "type": "string", + "maxLength": 128 + }, + "type": { + "description": "Type 账户类型(bank/alipay)。", + "type": "string", + "enum": [ + "bank", + "alipay" + ] + }, + "user_id": { + "description": "UserID 收款账户归属用户ID。", + "type": "integer" + } + } + }, "dto.SuperPayoutAccountItem": { "type": "object", "properties": { @@ -9480,6 +9789,34 @@ const docTemplate = `{ } } }, + "dto.SuperPayoutAccountUpdateForm": { + "type": "object", + "properties": { + "account": { + "description": "Account 收款账号(可选)。", + "type": "string", + "maxLength": 128 + }, + "name": { + "description": "Name 账户名称/开户行(可选)。", + "type": "string", + "maxLength": 128 + }, + "realname": { + "description": "Realname 收款人姓名(可选)。", + "type": "string", + "maxLength": 128 + }, + "type": { + "description": "Type 账户类型(bank/alipay,可选)。", + "type": "string", + "enum": [ + "bank", + "alipay" + ] + } + } + }, "dto.SuperReportExportForm": { "type": "object", "properties": { @@ -9954,6 +10291,43 @@ const docTemplate = `{ } } }, + "dto.SuperUserProfileUpdateForm": { + "type": "object", + "properties": { + "avatar": { + "description": "Avatar 头像URL(可选,空字符串表示清空)。", + "type": "string" + }, + "bio": { + "description": "Bio 个人简介(可选,空字符串表示清空)。", + "type": "string" + }, + "gender": { + "description": "Gender 性别(可选)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Gender" + } + ] + }, + "id_card": { + "description": "IDCard 身份证号(可选,用于更新实名认证信息)。", + "type": "string" + }, + "is_real_name_verified": { + "description": "IsRealNameVerified 是否已实名认证(可选)。", + "type": "boolean" + }, + "nickname": { + "description": "Nickname 昵称(可选,空字符串表示清空)。", + "type": "string" + }, + "real_name": { + "description": "RealName 真实姓名(可选,用于更新实名认证信息)。", + "type": "string" + } + } + }, "dto.SuperUserRealNameResponse": { "type": "object", "properties": { @@ -10663,6 +11037,10 @@ const docTemplate = `{ "dto.UserItem": { "type": "object", "properties": { + "avatar": { + "description": "Avatar 用户头像URL(资料展示)。", + "type": "string" + }, "balance": { "description": "Balance 账户可用余额(分)。", "type": "integer" @@ -10671,18 +11049,38 @@ const docTemplate = `{ "description": "BalanceFrozen 账户冻结余额(分)。", "type": "integer" }, + "bio": { + "description": "Bio 用户个人简介。", + "type": "string" + }, "created_at": { "description": "CreatedAt 创建时间(RFC3339)。", "type": "string" }, + "gender": { + "description": "Gender 用户性别(male/female/secret)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Gender" + } + ] + }, "id": { "description": "ID 用户ID。", "type": "integer" }, + "is_real_name_verified": { + "description": "IsRealNameVerified 是否已实名认证。", + "type": "boolean" + }, "joined_tenant_count": { "description": "JoinedTenantCount 加入的租户数量。", "type": "integer" }, + "nickname": { + "description": "Nickname 用户昵称(资料展示)。", + "type": "string" + }, "owned_tenant_count": { "description": "OwnedTenantCount 拥有的租户数量。", "type": "integer" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 7eaa68e..5c27cab 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -991,6 +991,121 @@ } } }, + "/super/v1/creators/{tenantID}/payout-accounts": { + "post": { + "description": "Create payout account for tenant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Create payout account", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "description": "Create form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperPayoutAccountCreateForm" + } + } + ], + "responses": { + "200": { + "description": "Created", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/creators/{tenantID}/settings": { + "get": { + "description": "Get creator settings by tenant ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Creator" + ], + "summary": "Get creator settings", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "tenantID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.Settings" + } + } + } + }, + "put": { + "description": "Update creator settings by tenant ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Creator" + ], + "summary": "Update creator settings", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "tenantID", + "in": "path", + "required": true + }, + { + "description": "Settings form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.Settings" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/finance/anomalies/balances": { "get": { "description": "List balance anomalies across users", @@ -1352,6 +1467,48 @@ } } }, + "/super/v1/notifications/templates/{id}": { + "patch": { + "description": "Update notification template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Update notification template", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Template ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateItem" + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -1669,6 +1826,46 @@ } } } + }, + "patch": { + "description": "Update payout account across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Update payout account", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Payout account ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperPayoutAccountUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } } }, "/super/v1/payout-accounts/{id}/review": { @@ -2807,6 +3004,46 @@ } } } + }, + "patch": { + "description": "Update user profile fields", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Update user profile", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperUserProfileUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "Updated", + "schema": { + "type": "string" + } + } + } } }, "/super/v1/users/{id}/coupons": { @@ -9093,6 +9330,39 @@ } } }, + "dto.SuperNotificationTemplateUpdateForm": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容(可选)。", + "type": "string" + }, + "is_active": { + "description": "IsActive 是否启用(可选)。", + "type": "boolean" + }, + "name": { + "description": "Name 模板名称(可选)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(不传代表不修改租户归属)。", + "type": "integer" + }, + "title": { + "description": "Title 通知标题(可选)。", + "type": "string" + }, + "type": { + "description": "Type 通知类型(system/order/audit/interaction,可选)。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + } + } + }, "dto.SuperOrderAnomalyItem": { "type": "object", "properties": { @@ -9377,6 +9647,45 @@ } } }, + "dto.SuperPayoutAccountCreateForm": { + "type": "object", + "required": [ + "account", + "name", + "realname", + "type", + "user_id" + ], + "properties": { + "account": { + "description": "Account 收款账号。", + "type": "string", + "maxLength": 128 + }, + "name": { + "description": "Name 账户名称/开户行。", + "type": "string", + "maxLength": 128 + }, + "realname": { + "description": "Realname 收款人姓名。", + "type": "string", + "maxLength": 128 + }, + "type": { + "description": "Type 账户类型(bank/alipay)。", + "type": "string", + "enum": [ + "bank", + "alipay" + ] + }, + "user_id": { + "description": "UserID 收款账户归属用户ID。", + "type": "integer" + } + } + }, "dto.SuperPayoutAccountItem": { "type": "object", "properties": { @@ -9474,6 +9783,34 @@ } } }, + "dto.SuperPayoutAccountUpdateForm": { + "type": "object", + "properties": { + "account": { + "description": "Account 收款账号(可选)。", + "type": "string", + "maxLength": 128 + }, + "name": { + "description": "Name 账户名称/开户行(可选)。", + "type": "string", + "maxLength": 128 + }, + "realname": { + "description": "Realname 收款人姓名(可选)。", + "type": "string", + "maxLength": 128 + }, + "type": { + "description": "Type 账户类型(bank/alipay,可选)。", + "type": "string", + "enum": [ + "bank", + "alipay" + ] + } + } + }, "dto.SuperReportExportForm": { "type": "object", "properties": { @@ -9948,6 +10285,43 @@ } } }, + "dto.SuperUserProfileUpdateForm": { + "type": "object", + "properties": { + "avatar": { + "description": "Avatar 头像URL(可选,空字符串表示清空)。", + "type": "string" + }, + "bio": { + "description": "Bio 个人简介(可选,空字符串表示清空)。", + "type": "string" + }, + "gender": { + "description": "Gender 性别(可选)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Gender" + } + ] + }, + "id_card": { + "description": "IDCard 身份证号(可选,用于更新实名认证信息)。", + "type": "string" + }, + "is_real_name_verified": { + "description": "IsRealNameVerified 是否已实名认证(可选)。", + "type": "boolean" + }, + "nickname": { + "description": "Nickname 昵称(可选,空字符串表示清空)。", + "type": "string" + }, + "real_name": { + "description": "RealName 真实姓名(可选,用于更新实名认证信息)。", + "type": "string" + } + } + }, "dto.SuperUserRealNameResponse": { "type": "object", "properties": { @@ -10657,6 +11031,10 @@ "dto.UserItem": { "type": "object", "properties": { + "avatar": { + "description": "Avatar 用户头像URL(资料展示)。", + "type": "string" + }, "balance": { "description": "Balance 账户可用余额(分)。", "type": "integer" @@ -10665,18 +11043,38 @@ "description": "BalanceFrozen 账户冻结余额(分)。", "type": "integer" }, + "bio": { + "description": "Bio 用户个人简介。", + "type": "string" + }, "created_at": { "description": "CreatedAt 创建时间(RFC3339)。", "type": "string" }, + "gender": { + "description": "Gender 用户性别(male/female/secret)。", + "allOf": [ + { + "$ref": "#/definitions/consts.Gender" + } + ] + }, "id": { "description": "ID 用户ID。", "type": "integer" }, + "is_real_name_verified": { + "description": "IsRealNameVerified 是否已实名认证。", + "type": "boolean" + }, "joined_tenant_count": { "description": "JoinedTenantCount 加入的租户数量。", "type": "integer" }, + "nickname": { + "description": "Nickname 用户昵称(资料展示)。", + "type": "string" + }, "owned_tenant_count": { "description": "OwnedTenantCount 拥有的租户数量。", "type": "integer" diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 93e4ff1..5c24dcd 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -2032,6 +2032,28 @@ definitions: description: UpdatedAt 更新时间(RFC3339)。 type: string type: object + dto.SuperNotificationTemplateUpdateForm: + properties: + content: + description: Content 通知内容(可选)。 + type: string + is_active: + description: IsActive 是否启用(可选)。 + type: boolean + name: + description: Name 模板名称(可选)。 + type: string + tenant_id: + description: TenantID 租户ID(不传代表不修改租户归属)。 + type: integer + title: + description: Title 通知标题(可选)。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.NotificationType' + description: Type 通知类型(system/order/audit/interaction,可选)。 + type: object dto.SuperOrderAnomalyItem: properties: amount_paid: @@ -2219,6 +2241,36 @@ definitions: description: Reason 退款原因说明。 type: string type: object + dto.SuperPayoutAccountCreateForm: + properties: + account: + description: Account 收款账号。 + maxLength: 128 + type: string + name: + description: Name 账户名称/开户行。 + maxLength: 128 + type: string + realname: + description: Realname 收款人姓名。 + maxLength: 128 + type: string + type: + description: Type 账户类型(bank/alipay)。 + enum: + - bank + - alipay + type: string + user_id: + description: UserID 收款账户归属用户ID。 + type: integer + required: + - account + - name + - realname + - type + - user_id + type: object dto.SuperPayoutAccountItem: properties: account: @@ -2288,6 +2340,27 @@ definitions: required: - action type: object + dto.SuperPayoutAccountUpdateForm: + properties: + account: + description: Account 收款账号(可选)。 + maxLength: 128 + type: string + name: + description: Name 账户名称/开户行(可选)。 + maxLength: 128 + type: string + realname: + description: Realname 收款人姓名(可选)。 + maxLength: 128 + type: string + type: + description: Type 账户类型(bank/alipay,可选)。 + enum: + - bank + - alipay + type: string + type: object dto.SuperReportExportForm: properties: end_at: @@ -2609,6 +2682,31 @@ definitions: description: Type 通知类型。 type: string type: object + dto.SuperUserProfileUpdateForm: + properties: + avatar: + description: Avatar 头像URL(可选,空字符串表示清空)。 + type: string + bio: + description: Bio 个人简介(可选,空字符串表示清空)。 + type: string + gender: + allOf: + - $ref: '#/definitions/consts.Gender' + description: Gender 性别(可选)。 + id_card: + description: IDCard 身份证号(可选,用于更新实名认证信息)。 + type: string + is_real_name_verified: + description: IsRealNameVerified 是否已实名认证(可选)。 + type: boolean + nickname: + description: Nickname 昵称(可选,空字符串表示清空)。 + type: string + real_name: + description: RealName 真实姓名(可选,用于更新实名认证信息)。 + type: string + type: object dto.SuperUserRealNameResponse: properties: id_card_masked: @@ -3106,21 +3204,37 @@ definitions: type: object dto.UserItem: properties: + avatar: + description: Avatar 用户头像URL(资料展示)。 + type: string balance: description: Balance 账户可用余额(分)。 type: integer balance_frozen: description: BalanceFrozen 账户冻结余额(分)。 type: integer + bio: + description: Bio 用户个人简介。 + type: string created_at: description: CreatedAt 创建时间(RFC3339)。 type: string + gender: + allOf: + - $ref: '#/definitions/consts.Gender' + description: Gender 用户性别(male/female/secret)。 id: description: ID 用户ID。 type: integer + is_real_name_verified: + description: IsRealNameVerified 是否已实名认证。 + type: boolean joined_tenant_count: description: JoinedTenantCount 加入的租户数量。 type: integer + nickname: + description: Nickname 用户昵称(资料展示)。 + type: string owned_tenant_count: description: OwnedTenantCount 拥有的租户数量。 type: integer @@ -4060,6 +4174,83 @@ paths: summary: List creators tags: - Creator + /super/v1/creators/{tenantID}/payout-accounts: + post: + consumes: + - application/json + description: Create payout account for tenant + parameters: + - description: Tenant ID + format: int64 + in: path + name: tenantID + required: true + type: integer + - description: Create form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperPayoutAccountCreateForm' + produces: + - application/json + responses: + "200": + description: Created + schema: + type: string + summary: Create payout account + tags: + - Finance + /super/v1/creators/{tenantID}/settings: + get: + consumes: + - application/json + description: Get creator settings by tenant ID + parameters: + - description: Tenant ID + format: int64 + in: path + name: tenantID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.Settings' + summary: Get creator settings + tags: + - Creator + put: + consumes: + - application/json + description: Update creator settings by tenant ID + parameters: + - description: Tenant ID + format: int64 + in: path + name: tenantID + required: true + type: integer + - description: Settings form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.Settings' + produces: + - application/json + responses: + "200": + description: Updated + schema: + type: string + summary: Update creator settings + tags: + - Creator /super/v1/finance/anomalies/balances: get: consumes: @@ -4281,6 +4472,34 @@ paths: summary: Create notification template tags: - Notification + /super/v1/notifications/templates/{id}: + patch: + consumes: + - application/json + description: Update notification template + parameters: + - description: Template ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Update form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperNotificationTemplateUpdateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.SuperNotificationTemplateItem' + summary: Update notification template + tags: + - Notification /super/v1/orders: get: consumes: @@ -4486,6 +4705,33 @@ paths: summary: Remove payout account tags: - Finance + patch: + consumes: + - application/json + description: Update payout account across tenants + parameters: + - description: Payout account ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Update form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperPayoutAccountUpdateForm' + produces: + - application/json + responses: + "200": + description: Updated + schema: + type: string + summary: Update payout account + tags: + - Finance /super/v1/payout-accounts/{id}/review: post: consumes: @@ -5187,6 +5433,33 @@ paths: summary: Get user tags: - User + patch: + consumes: + - application/json + description: Update user profile fields + parameters: + - description: User ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Update form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperUserProfileUpdateForm' + produces: + - application/json + responses: + "200": + description: Updated + schema: + type: string + summary: Update user profile + tags: + - User /super/v1/users/{id}/coupons: get: consumes: diff --git a/docs/storage_minio_smoke_test.md b/docs/storage_minio_smoke_test.md new file mode 100644 index 0000000..8c21f09 --- /dev/null +++ b/docs/storage_minio_smoke_test.md @@ -0,0 +1,65 @@ +# MinIO Storage Smoke Test + +This note records local MinIO setup for S3-compatible storage simulation. + +## Docker setup (local) + +Image sources used in this environment: +- `docker.hub.ipao.vip/minio/minio:latest` +- `docker.hub.ipao.vip/minio/mc:latest` + +Start MinIO (console on 9001): + +```bash +sudo -n docker run -d --name quyun-minio \ + -p 9000:9000 -p 9001:9001 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + docker.hub.ipao.vip/minio/minio:latest server /data --console-address ":9001" +``` + +## Bucket init + +```bash +mkdir -p /tmp/quyun-mc +sudo -n docker run --rm --network host -v /tmp/quyun-mc:/root/.mc docker.hub.ipao.vip/minio/mc:latest \ + alias set local http://127.0.0.1:9000 minioadmin minioadmin +sudo -n docker run --rm --network host -v /tmp/quyun-mc:/root/.mc docker.hub.ipao.vip/minio/mc:latest \ + mb -p local/quyun-assets +sudo -n docker run --rm --network host -v /tmp/quyun-mc:/root/.mc docker.hub.ipao.vip/minio/mc:latest \ + ls local +``` + +## Config sample + +```toml +[Storage] +Type = "s3" +AccessKey = "minioadmin" +SecretKey = "minioadmin" +Region = "us-east-1" +Bucket = "quyun-assets" +Endpoint = "http://127.0.0.1:9000" +PathStyle = true +``` + +Use the bundled config file for local runs: + +```bash +ENV_LOCAL=minio make serve +``` + +## Smoke test (optional) + +```bash +sudo -n docker run --rm --network host -v /tmp/quyun-mc:/root/.mc -v "$(pwd)":/work -w /work docker.hub.ipao.vip/minio/mc:latest \ + cp ./README.md local/quyun-assets/smoke/README.md +sudo -n docker run --rm --network host -v /tmp/quyun-mc:/root/.mc docker.hub.ipao.vip/minio/mc:latest \ + ls local/quyun-assets/smoke +``` + +## Cleanup + +```bash +sudo -n docker rm -f quyun-minio +``` diff --git a/docs/storage_provider.md b/docs/storage_provider.md index 7cf36ee..7aebe2c 100644 --- a/docs/storage_provider.md +++ b/docs/storage_provider.md @@ -30,3 +30,5 @@ PathStyle = true ``` For AWS S3, set `Endpoint` with `https://` (e.g., `https://s3.amazonaws.com`) and keep `PathStyle` as `false` unless your provider requires path-style access. + +For local MinIO setup and a smoke test, see `docs/storage_minio_smoke_test.md` (includes `ENV_LOCAL=minio`). diff --git a/docs/todo_list.md b/docs/todo_list.md index e1929df..fb760a5 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -5,7 +5,7 @@ ## 当前范围与约束 - 认证仅使用 JWT(不做 OAuth/Cookie 方案)。 - 支付集成暂不做,订单/退款仅按既有数据结构做流程与统计。 -- 存储仅使用本地 FS 模拟,真实 Provider 延后统一接入。 +- 存储需要接入本地 MinIO 进行真实 Provider 模拟,保留本地 FS 作为兜底。 - 多租户路由强隔离(`/t/:tenantCode/v1` + TenantResolver)已启用,后续仅做细节优化。 ## 统一原则 @@ -63,11 +63,63 @@ **测试方案** - owner 与非 owner 上传分支。 +### 4) 超管全量可编辑(补齐写操作缺口)(已完成) +**需求目标** +- 超管可对系统内现有对象执行“修改/新增/删除/审核”等操作,不受租户边界限制。 +- 补齐已存在能力但缺少 `/super/v1` 写入口的模块。 + +**技术方案(后端)** +- 租户创建完善 + - `POST /super/v1/tenants`:补齐 `expired_at`(基于 `duration`)与 `tenant_users` 管理员成员关系写入。 +- 创作者设置超管入口 + - 新增 `GET /super/v1/creators/:tenantID/settings` + - 新增 `PUT /super/v1/creators/:tenantID/settings` + - 复用 `services.Creator.GetSettings/UpdateSettings`,由超管直传 tenantID + operatorID。 +- 收款账户超管管理 + - 新增 `POST /super/v1/creators/:tenantID/payout-accounts` + - 新增 `PATCH /super/v1/payout-accounts/:id`(编辑/禁用/备注) + - 复用现有收款账户结构体与校验逻辑。 +- 用户资料超管编辑 + - 新增 `PATCH /super/v1/users/:id`(允许更新昵称/头像/实名标记等基础字段)。 + +**测试方案** +- CreateTenant 后 `expired_at` 与 `tenant_users` 均落库。 +- 超管可读取/更新创作者设置,权限校验通过。 +- 超管可新增/编辑收款账户。 +- 超管可更新用户资料,字段校验生效。 + +### 5) 通知模板支持编辑(已完成) +**需求目标** +- 支持模板更新、启用/停用,避免误建模板不可修正。 + +**技术方案(后端/前端)** +- API + - 新增 `PATCH /super/v1/notifications/templates/:id` + - (可选)新增 `DELETE /super/v1/notifications/templates/:id` +- 前端:`Notifications.vue` 增加“编辑模板”弹窗与启用开关。 + +**测试方案** +- 编辑模板后列表与详情一致。 +- 禁用模板后不允许用于群发(或提示不可用)。 + +### 6) 本地 MinIO 模拟真实存储 Provider(已完成) +**需求目标** +- 本地环境可用 MinIO 模拟真实 S3 Provider,验证上传/访问链路。 + +**技术方案(后端/本地环境)** +- Docker:拉起 MinIO(含 access/secret、bucket 初始化)。 +- `Storage` 配置支持 S3/MinIO 模式(endpoint + path style)。 +- 更新 `docs/storage_provider.md` 与新增 smoke test 文档。 + +**测试方案** +- 上传 -> MinIO -> 读取 URL 可访问。 +- 本地 FS 与 MinIO 配置切换可用。 + --- ## P1(高优先) -### 4) ID 类型统一(int64 / model 注入)(已完成) +### 7) ID 类型统一(int64 / model 注入)(已完成) **需求目标** - 所有业务 ID 使用 `int64`,路由参数统一 `:id`。 @@ -79,7 +131,7 @@ **测试方案** - 关键接口:正常请求 + 参数类型错误时返回明确错误。 -### 5) 内容访问策略完善(资源权限与预览差异化)(已完成) +### 8) 内容访问策略完善(资源权限与预览差异化)(已完成) **需求目标** - 媒体资源访问遵循:未购仅预览,已购全量,作者/管理员全量。 - 签名 URL 或下载地址生成前进行权限校验。 @@ -96,7 +148,7 @@ **测试方案** - 未登录/未购/已购/作者/管理员的可见资源集合一致性。 -### 6) 审计参数传递规范化(已完成) +### 9) 审计参数传递规范化(已完成) **需求目标** - 审计服务禁止自行读取 `ctx`,改为显式传入操作者信息。 @@ -111,7 +163,7 @@ ## P2(中优先) -### 7) 运营统计报表(曝光/转化/订单/退款)(已完成) +### 10) 运营统计报表(曝光/转化/订单/退款)(已完成) **需求目标** - 提供租户维度与时间范围的核心指标统计与导出。 @@ -127,7 +179,7 @@ **测试方案** - 统计口径一致性;筛选组合;导出任务可用性。 -### 8) 超管后台治理能力(健康度/异常监控/内容审核)(已完成) +### 11) 超管后台治理能力(健康度/异常监控/内容审核)(已完成) **需求目标** - 提供超管对租户的健康指标、异常趋势、内容合规审核。 @@ -142,7 +194,7 @@ **测试方案** - 审核状态流转有效性;异常阈值命中结果。 -### 9) 性能优化(避免 N+1)(已完成) +### 12) 性能优化(避免 N+1)(已完成) **需求目标** - 列表/统计场景避免逐条查询。 @@ -157,17 +209,17 @@ ## P3(延后) -### 10) 真实存储 Provider 接入 +### 13) 真实存储 Provider 接入(生产) **需求目标** -- 接入 OSS/云存储,统一上传/访问路径策略。 +- 接入 OSS/云存储(生产环境),统一上传/访问路径策略。 **技术方案(后端)** -- 通过配置注入 Provider,保留本地 FS 作为 dev fallback。 +- 通过配置注入 Provider,保留本地 FS/MinIO 作为 dev fallback。 **测试方案** -- 本地 FS + 真实 Provider 两套配置可用性。 +- 本地 FS + MinIO + 真实 Provider 三套配置可用性。 -### 11) 支付集成 +### 14) 支付集成 **需求目标** - 最终阶段对接真实支付。 @@ -191,6 +243,9 @@ - ID 类型已统一为 int64(仅保留 upload_id/external_id/uuid 等非数字标识)。 - 内容资源权限与预览差异化(未购预览、已购/管理员/成员全量)。 - 审计操作显式传入操作者信息(服务层不再依赖 ctx 读取)。 +- 超管全量可编辑(租户创建补齐、创作者设置/结算账户/用户资料写入口)。 +- 通知模板支持编辑(超管接口 + 前端编辑入口)。 +- 本地 MinIO 模拟真实存储 Provider(本地容器与文档指引)。 - 运营统计报表(overview + CSV 导出基础版)。 - 超管后台治理能力(健康度/异常监控/内容审核)。 - 性能优化(避免 N+1:订单/租户列表批量聚合 + topics 聚合)。 diff --git a/frontend/superadmin/src/service/CreatorService.js b/frontend/superadmin/src/service/CreatorService.js index 1e5b4e8..d2b7826 100644 --- a/frontend/superadmin/src/service/CreatorService.js +++ b/frontend/superadmin/src/service/CreatorService.js @@ -39,6 +39,23 @@ export const CreatorService = { items: normalizeItems(data?.items) }; }, + async getCreatorSettings(tenantID) { + if (!tenantID) throw new Error('tenantID is required'); + return requestJson(`/super/v1/creators/${tenantID}/settings`); + }, + async updateCreatorSettings(tenantID, { name, bio, avatar, cover, description } = {}) { + if (!tenantID) throw new Error('tenantID is required'); + return requestJson(`/super/v1/creators/${tenantID}/settings`, { + method: 'PUT', + body: { + name, + bio, + avatar, + cover, + description + } + }); + }, async listJoinRequests({ page, limit, tenant_id, tenant_code, tenant_name, user_id, username, status, created_at_from, created_at_to } = {}) { const iso = (d) => { if (!d) return undefined; @@ -140,6 +157,31 @@ export const CreatorService = { items: normalizeItems(data?.items) }; }, + async createPayoutAccount(tenantID, { user_id, type, name, account, realname } = {}) { + if (!tenantID) throw new Error('tenantID is required'); + return requestJson(`/super/v1/creators/${tenantID}/payout-accounts`, { + method: 'POST', + body: { + user_id, + type, + name, + account, + realname + } + }); + }, + async updatePayoutAccount(accountID, { type, name, account, realname } = {}) { + if (!accountID) throw new Error('accountID is required'); + return requestJson(`/super/v1/payout-accounts/${accountID}`, { + method: 'PATCH', + body: { + type, + name, + account, + realname + } + }); + }, async removePayoutAccount(accountID) { if (!accountID) throw new Error('accountID is required'); return requestJson(`/super/v1/payout-accounts/${accountID}`, { method: 'DELETE' }); diff --git a/frontend/superadmin/src/service/NotificationService.js b/frontend/superadmin/src/service/NotificationService.js index bf12df3..6bc9bb9 100644 --- a/frontend/superadmin/src/service/NotificationService.js +++ b/frontend/superadmin/src/service/NotificationService.js @@ -98,5 +98,19 @@ export const NotificationService = { is_active } }); + }, + async updateTemplate({ id, tenant_id, name, type, title, content, is_active } = {}) { + if (!id) throw new Error('template id is required'); + return requestJson(`/super/v1/notifications/templates/${id}`, { + method: 'PATCH', + body: { + tenant_id, + name, + type, + title, + content, + is_active + } + }); } }; diff --git a/frontend/superadmin/src/service/UserService.js b/frontend/superadmin/src/service/UserService.js index afd86a1..4c1b8f5 100644 --- a/frontend/superadmin/src/service/UserService.js +++ b/frontend/superadmin/src/service/UserService.js @@ -68,6 +68,20 @@ export const UserService = { if (!Array.isArray(roles) || roles.length === 0) throw new Error('roles is required'); return requestJson(`/super/v1/users/${userID}/roles`, { method: 'PATCH', body: { roles } }); }, + async updateUserProfile({ userID, nickname, avatar, gender, bio, is_real_name_verified, real_name, id_card } = {}) { + if (!userID) throw new Error('userID is required'); + + const body = {}; + if (nickname !== undefined) body.nickname = nickname; + if (avatar !== undefined) body.avatar = avatar; + if (gender !== undefined) body.gender = gender; + if (bio !== undefined) body.bio = bio; + if (is_real_name_verified !== undefined) body.is_real_name_verified = is_real_name_verified; + if (real_name !== undefined) body.real_name = real_name; + if (id_card !== undefined) body.id_card = id_card; + + return requestJson(`/super/v1/users/${userID}`, { method: 'PATCH', body }); + }, async getUserStatistics() { try { const data = await requestJson('/super/v1/users/statistics'); diff --git a/frontend/superadmin/src/views/superadmin/Creators.vue b/frontend/superadmin/src/views/superadmin/Creators.vue index e2ba215..2a1e240 100644 --- a/frontend/superadmin/src/views/superadmin/Creators.vue +++ b/frontend/superadmin/src/views/superadmin/Creators.vue @@ -34,6 +34,16 @@ const statusUpdating = ref(false); const statusTenant = ref(null); const statusValue = ref(null); +const settingsDialogVisible = ref(false); +const settingsLoading = ref(false); +const settingsSubmitting = ref(false); +const settingsTenant = ref(null); +const settingsName = ref(''); +const settingsBio = ref(''); +const settingsAvatar = ref(''); +const settingsCover = ref(''); +const settingsDescription = ref(''); + const applicationStatusOptions = computed(() => [{ label: '全部', value: '' }, ...(statusOptions.value || [])]); const joinRequests = ref([]); @@ -92,6 +102,11 @@ const payoutStatusOptions = [ { label: '已驳回', value: 'rejected' } ]; +const payoutTypeOptions = [ + { label: 'bank', value: 'bank' }, + { label: 'alipay', value: 'alipay' } +]; + const payoutReviewOptions = [ { label: '通过', value: 'approve' }, { label: '驳回', value: 'reject' } @@ -119,6 +134,17 @@ const payoutReviewAction = ref('approve'); const payoutReviewReason = ref(''); const payoutReviewTarget = ref(null); +const payoutDialogVisible = ref(false); +const payoutDialogMode = ref('create'); +const payoutDialogSubmitting = ref(false); +const payoutDialogEditingID = ref(null); +const payoutFormTenantID = ref(null); +const payoutFormUserID = ref(null); +const payoutFormType = ref('bank'); +const payoutFormName = ref(''); +const payoutFormAccount = ref(''); +const payoutFormRealname = ref(''); + const inviteDialogVisible = ref(false); const inviteSubmitting = ref(false); const inviteTenantID = ref(null); @@ -420,6 +446,54 @@ async function confirmUpdateStatus() { } } +async function openSettingsDialog(row) { + const tenantIDValue = Number(row?.id ?? row?.tenant_id ?? 0); + if (!tenantIDValue) return; + settingsTenant.value = row; + settingsDialogVisible.value = true; + settingsLoading.value = true; + try { + const data = await CreatorService.getCreatorSettings(tenantIDValue); + settingsName.value = data?.name ?? ''; + settingsBio.value = data?.bio ?? ''; + settingsAvatar.value = data?.avatar ?? ''; + settingsCover.value = data?.cover ?? ''; + settingsDescription.value = data?.description ?? ''; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载创作者设置', life: 4000 }); + } finally { + settingsLoading.value = false; + } +} + +async function confirmUpdateSettings() { + const tenantIDValue = Number(settingsTenant.value?.id ?? settingsTenant.value?.tenant_id ?? 0); + if (!tenantIDValue) return; + const name = settingsName.value.trim(); + if (!name) { + toast.add({ severity: 'warn', summary: '提示', detail: '名称不能为空', life: 3000 }); + return; + } + + settingsSubmitting.value = true; + try { + await CreatorService.updateCreatorSettings(tenantIDValue, { + name, + bio: settingsBio.value, + avatar: settingsAvatar.value, + cover: settingsCover.value, + description: settingsDescription.value + }); + toast.add({ severity: 'success', summary: '更新成功', detail: `TenantID: ${tenantIDValue}`, life: 3000 }); + settingsDialogVisible.value = false; + await loadCreators(); + } catch (error) { + toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新创作者设置', life: 4000 }); + } finally { + settingsSubmitting.value = false; + } +} + function openReviewDialog(row, action) { reviewTarget.value = row; reviewAction.value = action || 'approve'; @@ -529,6 +603,77 @@ async function confirmPayoutReview() { } } +function openPayoutDialog(mode, row) { + payoutDialogMode.value = mode; + if (mode === 'edit' && row) { + payoutDialogEditingID.value = row.id; + payoutFormTenantID.value = row.tenant_id ?? null; + payoutFormUserID.value = row.user_id ?? null; + payoutFormType.value = row.type || 'bank'; + payoutFormName.value = row.name || ''; + payoutFormAccount.value = row.account || ''; + payoutFormRealname.value = row.realname || ''; + } else { + payoutDialogEditingID.value = null; + payoutFormTenantID.value = null; + payoutFormUserID.value = null; + payoutFormType.value = 'bank'; + payoutFormName.value = ''; + payoutFormAccount.value = ''; + payoutFormRealname.value = ''; + } + payoutDialogVisible.value = true; +} + +async function confirmPayoutDialog() { + const tenantIDValue = Number(payoutFormTenantID.value ?? 0); + const userIDValue = Number(payoutFormUserID.value ?? 0); + const name = payoutFormName.value.trim(); + const account = payoutFormAccount.value.trim(); + const realname = payoutFormRealname.value.trim(); + + if (!name || !account || !realname) { + toast.add({ severity: 'warn', summary: '提示', detail: '请完善账户信息', life: 3000 }); + return; + } + if (payoutDialogMode.value === 'create' && (!tenantIDValue || !userIDValue)) { + toast.add({ severity: 'warn', summary: '提示', detail: 'TenantID 与 UserID 不能为空', life: 3000 }); + return; + } + + payoutDialogSubmitting.value = true; + try { + if (payoutDialogMode.value === 'edit') { + const accountID = payoutDialogEditingID.value; + if (!accountID) return; + await CreatorService.updatePayoutAccount(accountID, { + type: payoutFormType.value, + name, + account, + realname + }); + toast.add({ severity: 'success', summary: '更新成功', detail: `账户ID: ${accountID}`, life: 3000 }); + } else { + await CreatorService.createPayoutAccount(tenantIDValue, { + user_id: userIDValue, + type: payoutFormType.value, + name, + account, + realname + }); + toast.add({ severity: 'success', summary: '已创建', detail: '结算账户已创建', life: 3000 }); + } + payoutDialogVisible.value = false; + await loadPayoutAccounts(); + } catch (error) { + const summary = payoutDialogMode.value === 'edit' ? '更新失败' : '创建失败'; + const detail = payoutDialogMode.value === 'edit' ? '无法更新结算账户' : '无法创建结算账户'; + toast.add({ severity: 'error', summary, detail: error?.message || detail, life: 4000 }); + } finally { + payoutDialogSubmitting.value = false; + } +} + function openInviteDialog(row) { inviteTenantID.value = Number(row?.tenant_id ?? row?.tenant?.id ?? 0) || null; inviteMaxUses.value = 1; @@ -663,6 +808,11 @@ onMounted(() => { {{ formatDate(data.created_at) }} + + + @@ -863,6 +1013,7 @@ onMounted(() => {

结算账户

跨租户结算账户审查 +