diff --git a/backend/app/http/v1/creator.go b/backend/app/http/v1/creator.go index f4612e4..2eaea47 100644 --- a/backend/app/http/v1/creator.go +++ b/backend/app/http/v1/creator.go @@ -29,6 +29,46 @@ func (c *Creator) Apply(ctx fiber.Ctx, user *models.User, form *dto.ApplyForm) e return services.Creator.Apply(ctx, tenantID, user.ID, form) } +// Review join request +// +// @Router /t/:tenantCode/v1/creator/members/:id/review [post] +// @Summary Review join request +// @Description Approve or reject a tenant join request +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param id path int64 true "Join request ID" +// @Param form body dto.TenantJoinReviewForm true "Review form" +// @Success 200 {string} string "Reviewed" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *Creator) ReviewMember(ctx fiber.Ctx, user *models.User, id int64, form *dto.TenantJoinReviewForm) error { + tenantID := getTenantID(ctx) + return services.Tenant.ReviewJoin(ctx, tenantID, user.ID, id, form) +} + +// Create member invite +// +// @Router /t/:tenantCode/v1/creator/members/invite [post] +// @Summary Create member invite +// @Description Create an invite for tenant members +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param form body dto.TenantInviteCreateForm true "Invite form" +// @Success 200 {object} dto.TenantInviteItem +// @Bind user local key(__ctx_user) +// @Bind form body +func (c *Creator) CreateMemberInvite( + ctx fiber.Ctx, + user *models.User, + form *dto.TenantInviteCreateForm, +) (*dto.TenantInviteItem, error) { + tenantID := getTenantID(ctx) + return services.Tenant.CreateInvite(ctx, tenantID, user.ID, form) +} + // Get creator dashboard stats // // @Router /t/:tenantCode/v1/creator/dashboard [get] diff --git a/backend/app/http/v1/dto/tenant_member.go b/backend/app/http/v1/dto/tenant_member.go new file mode 100644 index 0000000..fc7671a --- /dev/null +++ b/backend/app/http/v1/dto/tenant_member.go @@ -0,0 +1,46 @@ +package dto + +type TenantJoinApplyForm struct { + // Reason 申请加入原因(可选,空值会使用默认文案)。 + Reason string `json:"reason"` +} + +type TenantJoinReviewForm struct { + // Action 审核动作(approve/reject)。 + Action string `json:"action"` + // Reason 审核说明(可选,用于展示驳回原因或备注)。 + Reason string `json:"reason"` +} + +type TenantInviteCreateForm struct { + // MaxUses 最大可使用次数(<=0 默认 1)。 + MaxUses int32 `json:"max_uses"` + // ExpiresAt 过期时间(RFC3339,可选,空值使用默认过期时间)。 + ExpiresAt *string `json:"expires_at"` + // Remark 备注说明(可选)。 + Remark string `json:"remark"` +} + +type TenantInviteAcceptForm struct { + // Code 邀请码(必填)。 + Code string `json:"code"` +} + +type TenantInviteItem struct { + // ID 邀请记录ID。 + ID int64 `json:"id"` + // Code 邀请码。 + Code string `json:"code"` + // Status 邀请状态(active/disabled/expired)。 + Status string `json:"status"` + // MaxUses 最大可使用次数。 + MaxUses int32 `json:"max_uses"` + // UsedCount 已使用次数。 + UsedCount int32 `json:"used_count"` + // ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。 + ExpiresAt string `json:"expires_at"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // Remark 备注说明。 + Remark string `json:"remark"` +} diff --git a/backend/app/http/v1/routes.gen.go b/backend/app/http/v1/routes.gen.go index b281580..c7f656b 100644 --- a/backend/app/http/v1/routes.gen.go +++ b/backend/app/http/v1/routes.gen.go @@ -208,6 +208,19 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), Body[dto.ContentCreateForm]("form"), )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/members/:id/review -> creator.ReviewMember") + router.Post("/t/:tenantCode/v1/creator/members/:id/review"[len(r.Path()):], Func3( + r.creator.ReviewMember, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.TenantJoinReviewForm]("form"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/members/invite -> creator.CreateMemberInvite") + router.Post("/t/:tenantCode/v1/creator/members/invite"[len(r.Path()):], DataFunc2( + r.creator.CreateMemberInvite, + Local[*models.User]("__ctx_user"), + Body[dto.TenantInviteCreateForm]("form"), + )) r.log.Debugf("Registering route: Post /t/:tenantCode/v1/creator/orders/:id/refund -> creator.Refund") router.Post("/t/:tenantCode/v1/creator/orders/:id/refund"[len(r.Path()):], Func3( r.creator.Refund, @@ -260,6 +273,11 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), PathParam[int64]("id"), )) + r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/tenants/:id/join -> tenant.CancelJoin") + router.Delete("/t/:tenantCode/v1/tenants/:id/join"[len(r.Path()):], Func1( + r.tenant.CancelJoin, + PathParam[int64]("id"), + )) r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creators/:id/contents -> tenant.ListContents") router.Get("/t/:tenantCode/v1/creators/:id/contents"[len(r.Path()):], DataFunc2( r.tenant.ListContents, @@ -283,6 +301,18 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), PathParam[int64]("id"), )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/tenants/:id/invites/accept -> tenant.AcceptInvite") + router.Post("/t/:tenantCode/v1/tenants/:id/invites/accept"[len(r.Path()):], Func2( + r.tenant.AcceptInvite, + PathParam[int64]("id"), + Body[dto.TenantInviteAcceptForm]("form"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/tenants/:id/join -> tenant.ApplyJoin") + router.Post("/t/:tenantCode/v1/tenants/:id/join"[len(r.Path()):], Func2( + r.tenant.ApplyJoin, + PathParam[int64]("id"), + Body[dto.TenantJoinApplyForm]("form"), + )) // Register routes for controller: Transaction r.log.Debugf("Registering route: Get /t/:tenantCode/v1/orders/:id/status -> transaction.Status") router.Get("/t/:tenantCode/v1/orders/:id/status"[len(r.Path()):], DataFunc2( diff --git a/backend/app/http/v1/tenant.go b/backend/app/http/v1/tenant.go index d71bfa5..9974ca7 100644 --- a/backend/app/http/v1/tenant.go +++ b/backend/app/http/v1/tenant.go @@ -120,3 +120,67 @@ func (t *Tenant) Unfollow(ctx fiber.Ctx, user *models.User, id int64) error { } return services.Tenant.Unfollow(ctx, tenantID, user.ID) } + +// Apply to join a tenant +// +// @Router /t/:tenantCode/v1/tenants/:id/join [post] +// @Summary Apply to join tenant +// @Description Submit join request for a tenant +// @Tags TenantPublic +// @Accept json +// @Produce json +// @Param id path int64 true "Tenant ID" +// @Param form body dto.TenantJoinApplyForm true "Join form" +// @Success 200 {string} string "Applied" +// @Bind id path +// @Bind form body +func (t *Tenant) ApplyJoin(ctx fiber.Ctx, id int64, form *dto.TenantJoinApplyForm) error { + tenantID := getTenantID(ctx) + if tenantID > 0 && id != tenantID { + return errorx.ErrForbidden.WithMsg("租户不匹配") + } + userID := getUserID(ctx) + return services.Tenant.ApplyJoin(ctx, id, userID, form) +} + +// Cancel join request +// +// @Router /t/:tenantCode/v1/tenants/:id/join [delete] +// @Summary Cancel join request +// @Description Cancel join request for a tenant +// @Tags TenantPublic +// @Accept json +// @Produce json +// @Param id path int64 true "Tenant ID" +// @Success 200 {string} string "Canceled" +// @Bind id path +func (t *Tenant) CancelJoin(ctx fiber.Ctx, id int64) error { + tenantID := getTenantID(ctx) + if tenantID > 0 && id != tenantID { + return errorx.ErrForbidden.WithMsg("租户不匹配") + } + userID := getUserID(ctx) + return services.Tenant.CancelJoin(ctx, id, userID) +} + +// Accept tenant invite +// +// @Router /t/:tenantCode/v1/tenants/:id/invites/accept [post] +// @Summary Accept tenant invite +// @Description Accept a tenant invite by code +// @Tags TenantPublic +// @Accept json +// @Produce json +// @Param id path int64 true "Tenant ID" +// @Param form body dto.TenantInviteAcceptForm true "Invite form" +// @Success 200 {string} string "Accepted" +// @Bind id path +// @Bind form body +func (t *Tenant) AcceptInvite(ctx fiber.Ctx, id int64, form *dto.TenantInviteAcceptForm) error { + tenantID := getTenantID(ctx) + if tenantID > 0 && id != tenantID { + return errorx.ErrForbidden.WithMsg("租户不匹配") + } + userID := getUserID(ctx) + return services.Tenant.AcceptInvite(ctx, id, userID, form) +} diff --git a/backend/app/services/common_test.go b/backend/app/services/common_test.go index e6794d3..1fb757c 100644 --- a/backend/app/services/common_test.go +++ b/backend/app/services/common_test.go @@ -1,8 +1,12 @@ package services import ( + "bytes" "database/sql" "errors" + "mime/multipart" + "net/http" + "net/http/httptest" "testing" "quyun/v2/app/commands/testx" @@ -40,6 +44,9 @@ func (s *CommonTestSuite) Test_AbortUpload() { ctx := s.T().Context() tenantID := int64(1) ownerID := int64(1001) + tempDir := s.T().TempDir() + So(Common.storage, ShouldNotBeNil) + Common.storage.Config.LocalPath = tempDir newUpload := func() string { form := &common_dto.UploadInitForm{ @@ -84,3 +91,64 @@ func (s *CommonTestSuite) Test_AbortUpload() { }) }) } + +func (s *CommonTestSuite) Test_UploadPart() { + Convey("UploadPart", s.T(), func() { + ctx := s.T().Context() + tenantID := int64(1) + ownerID := int64(1001) + otherID := int64(2002) + tempDir := s.T().TempDir() + So(Common.storage, ShouldNotBeNil) + Common.storage.Config.LocalPath = tempDir + + newUpload := func() string { + form := &common_dto.UploadInitForm{ + Filename: "sample.mp4", + Type: "video", + MimeType: "video/mp4", + } + resp, err := Common.InitUpload(ctx, tenantID, ownerID, form) + So(err, ShouldBeNil) + So(resp.UploadID, ShouldNotBeBlank) + return resp.UploadID + } + + newFileHeader := func() *multipart.FileHeader { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("file", "sample.txt") + So(err, ShouldBeNil) + _, err = part.Write([]byte("hello world")) + So(err, ShouldBeNil) + + So(writer.Close(), ShouldBeNil) + + req := httptest.NewRequest(http.MethodPost, "/upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + So(req.ParseMultipartForm(32<<20), ShouldBeNil) + So(req.MultipartForm, ShouldNotBeNil) + So(req.MultipartForm.File["file"], ShouldNotBeEmpty) + return req.MultipartForm.File["file"][0] + } + + Convey("should allow owner to upload part", func() { + uploadID := newUpload() + form := &common_dto.UploadPartForm{UploadID: uploadID, PartNumber: 1} + err := Common.UploadPart(ctx, tenantID, ownerID, newFileHeader(), form) + So(err, ShouldBeNil) + }) + + Convey("should reject other user", func() { + uploadID := newUpload() + form := &common_dto.UploadPartForm{UploadID: uploadID, PartNumber: 1} + err := Common.UploadPart(ctx, tenantID, otherID, newFileHeader(), form) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrForbidden.Code) + }) + }) +} diff --git a/backend/app/services/tenant_member.go b/backend/app/services/tenant_member.go new file mode 100644 index 0000000..40ce991 --- /dev/null +++ b/backend/app/services/tenant_member.go @@ -0,0 +1,427 @@ +package services + +import ( + "context" + "errors" + "strings" + "time" + + "quyun/v2/app/errorx" + tenant_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "github.com/google/uuid" + "go.ipao.vip/gen/types" + "gorm.io/gorm" +) + +func (s *tenant) ApplyJoin(ctx context.Context, tenantID, userID int64, form *tenant_dto.TenantJoinApplyForm) error { + if userID == 0 { + return errorx.ErrUnauthorized + } + if tenantID == 0 { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + + // 校验租户可加入状态。 + tblTenant, qTenant := models.TenantQuery.QueryContext(ctx) + tenant, err := qTenant.Where(tblTenant.ID.Eq(tenantID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if tenant.Status != consts.TenantStatusVerified { + return errorx.ErrBadRequest.WithMsg("租户暂不可加入") + } + if tenant.UserID == userID { + return errorx.ErrBadRequest.WithMsg("您已是租户管理员") + } + + // 已是成员则不允许重复申请。 + tblMember, qMember := models.TenantUserQuery.QueryContext(ctx) + exists, err := qMember.Where(tblMember.TenantID.Eq(tenantID), tblMember.UserID.Eq(userID)).Exists() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if exists { + return errorx.ErrBadRequest.WithMsg("您已是租户成员") + } + + // 防止重复提交同一租户的待审核申请。 + tblReq, qReq := models.TenantJoinRequestQuery.QueryContext(ctx) + pendingExists, err := qReq.Where( + tblReq.TenantID.Eq(tenantID), + tblReq.UserID.Eq(userID), + tblReq.Status.Eq(string(consts.TenantJoinRequestStatusPending)), + ).Exists() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if pendingExists { + return errorx.ErrBadRequest.WithMsg("已提交申请") + } + + reason := "" + if form != nil { + reason = strings.TrimSpace(form.Reason) + } + if reason == "" { + reason = "申请加入" + } + + req := &models.TenantJoinRequest{ + TenantID: tenantID, + UserID: userID, + Status: string(consts.TenantJoinRequestStatusPending), + Reason: reason, + } + if err := qReq.Create(req); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil +} + +func (s *tenant) CancelJoin(ctx context.Context, tenantID, userID int64) error { + if userID == 0 { + return errorx.ErrUnauthorized + } + if tenantID == 0 { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + + tblReq, qReq := models.TenantJoinRequestQuery.QueryContext(ctx) + req, err := qReq.Where( + tblReq.TenantID.Eq(tenantID), + tblReq.UserID.Eq(userID), + tblReq.Status.Eq(string(consts.TenantJoinRequestStatusPending)), + ).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("申请不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + if _, err := qReq.Where(tblReq.ID.Eq(req.ID)).Delete(); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil +} + +func (s *tenant) ReviewJoin( + ctx context.Context, + tenantID, + operatorID, + requestID int64, + form *tenant_dto.TenantJoinReviewForm, +) error { + if tenantID == 0 { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if form == nil { + return errorx.ErrBadRequest.WithMsg("审核参数不能为空") + } + action := strings.ToLower(strings.TrimSpace(form.Action)) + if action != "approve" && action != "reject" { + return errorx.ErrBadRequest.WithMsg("审核动作无效") + } + + // 校验操作者为租户管理员或租户主账号。 + if _, err := s.ensureTenantAdmin(ctx, tenantID, operatorID); err != nil { + return err + } + + tblReq, qReq := models.TenantJoinRequestQuery.QueryContext(ctx) + req, err := qReq.Where(tblReq.ID.Eq(requestID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("申请不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if req.TenantID != tenantID { + return errorx.ErrForbidden.WithMsg("租户不匹配") + } + if req.Status != string(consts.TenantJoinRequestStatusPending) { + return errorx.ErrBadRequest.WithMsg("申请已处理") + } + + reason := strings.TrimSpace(form.Reason) + now := time.Now() + + if action == "reject" { + _, err = qReq.Where( + tblReq.ID.Eq(req.ID), + tblReq.Status.Eq(string(consts.TenantJoinRequestStatusPending)), + ).UpdateSimple( + tblReq.Status.Value(string(consts.TenantJoinRequestStatusRejected)), + tblReq.DecidedAt.Value(now), + tblReq.DecidedOperatorUserID.Value(operatorID), + tblReq.DecidedReason.Value(reason), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil + } + + // 审核通过需在事务内写入成员并更新申请状态。 + return models.Q.Transaction(func(tx *models.Query) error { + tblMember, qMember := tx.TenantUser.QueryContext(ctx) + exists, err := qMember.Where( + tblMember.TenantID.Eq(tenantID), + tblMember.UserID.Eq(req.UserID), + ).Exists() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if exists { + return errorx.ErrBadRequest.WithMsg("用户已是成员") + } + + member := &models.TenantUser{ + TenantID: tenantID, + UserID: req.UserID, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, + Status: consts.UserStatusVerified, + } + if err := qMember.Create(member); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + tblReqTx, qReqTx := tx.TenantJoinRequest.QueryContext(ctx) + _, err = qReqTx.Where( + tblReqTx.ID.Eq(req.ID), + tblReqTx.Status.Eq(string(consts.TenantJoinRequestStatusPending)), + ).UpdateSimple( + tblReqTx.Status.Value(string(consts.TenantJoinRequestStatusApproved)), + tblReqTx.DecidedAt.Value(now), + tblReqTx.DecidedOperatorUserID.Value(operatorID), + tblReqTx.DecidedReason.Value(reason), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil + }) +} + +func (s *tenant) CreateInvite( + ctx context.Context, + tenantID, + operatorID int64, + form *tenant_dto.TenantInviteCreateForm, +) (*tenant_dto.TenantInviteItem, error) { + if tenantID == 0 { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if form == nil { + return nil, errorx.ErrBadRequest.WithMsg("邀请参数不能为空") + } + + // 校验操作者权限。 + if _, err := s.ensureTenantAdmin(ctx, tenantID, operatorID); err != nil { + return nil, err + } + + maxUses := form.MaxUses + if maxUses <= 0 { + maxUses = 1 + } + expiresAt, err := s.normalizeInviteExpiry(form) + if err != nil { + return nil, err + } + remark := strings.TrimSpace(form.Remark) + if remark == "" { + remark = "成员邀请" + } + + code, err := s.newInviteCode(ctx) + if err != nil { + return nil, err + } + + invite := &models.TenantInvite{ + TenantID: tenantID, + UserID: operatorID, + Code: code, + Status: string(consts.TenantInviteStatusActive), + MaxUses: maxUses, + UsedCount: 0, + ExpiresAt: expiresAt, + Remark: remark, + } + if err := models.TenantInviteQuery.WithContext(ctx).Create(invite); err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + return s.toTenantInviteItem(invite), nil +} + +func (s *tenant) AcceptInvite(ctx context.Context, tenantID, userID int64, form *tenant_dto.TenantInviteAcceptForm) error { + if userID == 0 { + return errorx.ErrUnauthorized + } + if tenantID == 0 { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if form == nil || strings.TrimSpace(form.Code) == "" { + return errorx.ErrBadRequest.WithMsg("邀请码不能为空") + } + + code := strings.TrimSpace(form.Code) + now := time.Now() + + // 邀请校验 + 成员入库需要事务保证一致性。 + return models.Q.Transaction(func(tx *models.Query) error { + tblInvite, qInvite := tx.TenantInvite.QueryContext(ctx) + invite, err := qInvite.Where( + tblInvite.TenantID.Eq(tenantID), + tblInvite.Code.Eq(code), + ).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("邀请码无效") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + if invite.Status != string(consts.TenantInviteStatusActive) { + return errorx.ErrBadRequest.WithMsg("邀请码不可用") + } + if !invite.ExpiresAt.IsZero() && invite.ExpiresAt.Before(now) { + _, err = qInvite.Where(tblInvite.ID.Eq(invite.ID)).UpdateSimple( + tblInvite.Status.Value(string(consts.TenantInviteStatusExpired)), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return errorx.ErrBadRequest.WithMsg("邀请码已过期") + } + if invite.UsedCount >= invite.MaxUses { + return errorx.ErrBadRequest.WithMsg("邀请码已用尽") + } + + tblMember, qMember := tx.TenantUser.QueryContext(ctx) + exists, err := qMember.Where( + tblMember.TenantID.Eq(tenantID), + tblMember.UserID.Eq(userID), + ).Exists() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if exists { + return errorx.ErrBadRequest.WithMsg("您已是租户成员") + } + + member := &models.TenantUser{ + TenantID: tenantID, + UserID: userID, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, + Status: consts.UserStatusVerified, + } + if err := qMember.Create(member); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + _, err = qInvite.Where(tblInvite.ID.Eq(invite.ID)).UpdateSimple( + tblInvite.UsedCount.Value(invite.UsedCount + 1), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil + }) +} + +func (s *tenant) ensureTenantAdmin(ctx context.Context, tenantID, userID int64) (*models.Tenant, error) { + if userID == 0 { + return nil, errorx.ErrUnauthorized + } + + tblTenant, qTenant := models.TenantQuery.QueryContext(ctx) + tenant, err := qTenant.Where(tblTenant.ID.Eq(tenantID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if tenant.UserID == userID { + return tenant, nil + } + + // 非主账号需校验租户管理员角色。 + tblMember, qMember := models.TenantUserQuery.QueryContext(ctx) + exists, err := qMember.Where( + tblMember.TenantID.Eq(tenantID), + tblMember.UserID.Eq(userID), + tblMember.Status.Eq(consts.UserStatusVerified), + tblMember.Role.Contains(types.Array[consts.TenantUserRole]{consts.TenantUserRoleTenantAdmin}), + ).Exists() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if !exists { + return nil, errorx.ErrPermissionDenied.WithMsg("无权限操作该租户") + } + return tenant, nil +} + +func (s *tenant) normalizeInviteExpiry(form *tenant_dto.TenantInviteCreateForm) (time.Time, error) { + if form == nil || form.ExpiresAt == nil || strings.TrimSpace(*form.ExpiresAt) == "" { + return time.Now().Add(7 * 24 * time.Hour), nil + } + raw := strings.TrimSpace(*form.ExpiresAt) + expireAt, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, errorx.ErrBadRequest.WithMsg("过期时间格式错误") + } + if expireAt.Before(time.Now()) { + return time.Time{}, errorx.ErrBadRequest.WithMsg("过期时间不能早于当前时间") + } + return expireAt, nil +} + +func (s *tenant) newInviteCode(ctx context.Context) (string, error) { + for i := 0; i < 5; i++ { + code := strings.ReplaceAll(uuid.NewString(), "-", "") + exists, err := models.TenantInviteQuery.WithContext(ctx). + Where(models.TenantInviteQuery.Code.Eq(code)). + Exists() + if err != nil { + return "", errorx.ErrDatabaseError.WithCause(err) + } + if !exists { + return code, nil + } + } + return "", errorx.ErrInternalError.WithMsg("生成邀请码失败") +} + +func (s *tenant) toTenantInviteItem(invite *models.TenantInvite) *tenant_dto.TenantInviteItem { + if invite == nil { + return nil + } + expiresAt := "" + if !invite.ExpiresAt.IsZero() { + expiresAt = invite.ExpiresAt.Format(time.RFC3339) + } + createdAt := "" + if !invite.CreatedAt.IsZero() { + createdAt = invite.CreatedAt.Format(time.RFC3339) + } + return &tenant_dto.TenantInviteItem{ + ID: invite.ID, + Code: invite.Code, + Status: invite.Status, + MaxUses: invite.MaxUses, + UsedCount: invite.UsedCount, + ExpiresAt: expiresAt, + CreatedAt: createdAt, + Remark: invite.Remark, + } +} diff --git a/backend/app/services/tenant_member_test.go b/backend/app/services/tenant_member_test.go new file mode 100644 index 0000000..502eaa0 --- /dev/null +++ b/backend/app/services/tenant_member_test.go @@ -0,0 +1,304 @@ +package services + +import ( + "errors" + "time" + + "quyun/v2/app/errorx" + tenant_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/database" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + . "github.com/smartystreets/goconvey/convey" + "go.ipao.vip/gen/types" +) + +func (s *TenantTestSuite) Test_ApplyJoin() { + Convey("ApplyJoin", s.T(), func() { + ctx := s.T().Context() + + setup := func() (*models.Tenant, *models.User, *models.User) { + database.Truncate(ctx, s.DB, + models.TableNameTenantJoinRequest, + models.TableNameTenantUser, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_apply", Phone: "13900001001"} + user := &models.User{Username: "user_apply", Phone: "13900001002"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + _ = models.UserQuery.WithContext(ctx).Create(user) + + tenant := &models.Tenant{ + Name: "Tenant Apply", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + return tenant, owner, user + } + + Convey("should create pending join request", func() { + tenant, _, user := setup() + + err := Tenant.ApplyJoin(ctx, tenant.ID, user.ID, &tenant_dto.TenantJoinApplyForm{Reason: "想加入"}) + So(err, ShouldBeNil) + + req, err := models.TenantJoinRequestQuery.WithContext(ctx). + Where(models.TenantJoinRequestQuery.TenantID.Eq(tenant.ID), + models.TenantJoinRequestQuery.UserID.Eq(user.ID)). + First() + So(err, ShouldBeNil) + So(req.Status, ShouldEqual, string(consts.TenantJoinRequestStatusPending)) + }) + + Convey("should reject duplicate apply", func() { + tenant, _, user := setup() + _ = Tenant.ApplyJoin(ctx, tenant.ID, user.ID, &tenant_dto.TenantJoinApplyForm{}) + + err := Tenant.ApplyJoin(ctx, tenant.ID, user.ID, &tenant_dto.TenantJoinApplyForm{}) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrBadRequest.Code) + }) + + Convey("should reject when already member", func() { + tenant, _, user := setup() + _ = models.TenantUserQuery.WithContext(ctx).Create(&models.TenantUser{ + TenantID: tenant.ID, + UserID: user.ID, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, + Status: consts.UserStatusVerified, + }) + + err := Tenant.ApplyJoin(ctx, tenant.ID, user.ID, &tenant_dto.TenantJoinApplyForm{}) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrBadRequest.Code) + }) + }) +} + +func (s *TenantTestSuite) Test_CancelJoin() { + Convey("CancelJoin", s.T(), func() { + ctx := s.T().Context() + + setup := func() (*models.Tenant, *models.User, *models.User, *models.TenantJoinRequest) { + database.Truncate(ctx, s.DB, + models.TableNameTenantJoinRequest, + models.TableNameTenantUser, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_cancel", Phone: "13900001003"} + user := &models.User{Username: "user_cancel", Phone: "13900001004"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + _ = models.UserQuery.WithContext(ctx).Create(user) + + tenant := &models.Tenant{ + Name: "Tenant Cancel", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + req := &models.TenantJoinRequest{ + TenantID: tenant.ID, + UserID: user.ID, + Status: string(consts.TenantJoinRequestStatusPending), + Reason: "测试取消", + } + _ = models.TenantJoinRequestQuery.WithContext(ctx).Create(req) + return tenant, owner, user, req + } + + Convey("should cancel pending request", func() { + tenant, _, user, _ := setup() + + err := Tenant.CancelJoin(ctx, tenant.ID, user.ID) + So(err, ShouldBeNil) + + exists, err := models.TenantJoinRequestQuery.WithContext(ctx). + Where(models.TenantJoinRequestQuery.TenantID.Eq(tenant.ID), + models.TenantJoinRequestQuery.UserID.Eq(user.ID)). + Exists() + So(err, ShouldBeNil) + So(exists, ShouldBeFalse) + }) + + Convey("should return not found when request missing", func() { + tenant, _, user, _ := setup() + _ = Tenant.CancelJoin(ctx, tenant.ID, user.ID) + + err := Tenant.CancelJoin(ctx, tenant.ID, user.ID) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrRecordNotFound.Code) + }) + }) +} + +func (s *TenantTestSuite) Test_ReviewJoin() { + Convey("ReviewJoin", s.T(), func() { + ctx := s.T().Context() + + setup := func() (*models.Tenant, *models.User, *models.User, *models.TenantJoinRequest) { + database.Truncate(ctx, s.DB, + models.TableNameTenantJoinRequest, + models.TableNameTenantUser, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_review", Phone: "13900001005"} + user := &models.User{Username: "user_review", Phone: "13900001006"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + _ = models.UserQuery.WithContext(ctx).Create(user) + + tenant := &models.Tenant{ + Name: "Tenant Review", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + req := &models.TenantJoinRequest{ + TenantID: tenant.ID, + UserID: user.ID, + Status: string(consts.TenantJoinRequestStatusPending), + Reason: "测试审核", + } + _ = models.TenantJoinRequestQuery.WithContext(ctx).Create(req) + return tenant, owner, user, req + } + + Convey("should approve join request", func() { + tenant, owner, user, req := setup() + + err := Tenant.ReviewJoin(ctx, tenant.ID, owner.ID, req.ID, &tenant_dto.TenantJoinReviewForm{ + Action: "approve", + Reason: "通过", + }) + So(err, ShouldBeNil) + + req, err = models.TenantJoinRequestQuery.WithContext(ctx).Where(models.TenantJoinRequestQuery.ID.Eq(req.ID)).First() + So(err, ShouldBeNil) + So(req.Status, ShouldEqual, string(consts.TenantJoinRequestStatusApproved)) + + exists, err := models.TenantUserQuery.WithContext(ctx). + Where(models.TenantUserQuery.TenantID.Eq(tenant.ID), + models.TenantUserQuery.UserID.Eq(user.ID)). + Exists() + So(err, ShouldBeNil) + So(exists, ShouldBeTrue) + }) + + Convey("should reject join request", func() { + tenant, owner, _, req := setup() + + err := Tenant.ReviewJoin(ctx, tenant.ID, owner.ID, req.ID, &tenant_dto.TenantJoinReviewForm{ + Action: "reject", + Reason: "不符合条件", + }) + So(err, ShouldBeNil) + + req, err = models.TenantJoinRequestQuery.WithContext(ctx).Where(models.TenantJoinRequestQuery.ID.Eq(req.ID)).First() + So(err, ShouldBeNil) + So(req.Status, ShouldEqual, string(consts.TenantJoinRequestStatusRejected)) + }) + }) +} + +func (s *TenantTestSuite) Test_CreateInvite() { + Convey("CreateInvite", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, + models.TableNameTenantInvite, + models.TableNameTenantUser, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_invite", Phone: "13900001007"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + tenant := &models.Tenant{ + Name: "Tenant Invite", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339) + item, err := Tenant.CreateInvite(ctx, tenant.ID, owner.ID, &tenant_dto.TenantInviteCreateForm{ + MaxUses: 2, + ExpiresAt: &expiresAt, + Remark: "测试邀请", + }) + So(err, ShouldBeNil) + So(item.Code, ShouldNotBeBlank) + + invite, err := models.TenantInviteQuery.WithContext(ctx).Where(models.TenantInviteQuery.ID.Eq(item.ID)).First() + So(err, ShouldBeNil) + So(invite.MaxUses, ShouldEqual, 2) + So(invite.Status, ShouldEqual, string(consts.TenantInviteStatusActive)) + }) +} + +func (s *TenantTestSuite) Test_AcceptInvite() { + Convey("AcceptInvite", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, + models.TableNameTenantInvite, + models.TableNameTenantUser, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_accept", Phone: "13900001008"} + user := &models.User{Username: "user_accept", Phone: "13900001009"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + _ = models.UserQuery.WithContext(ctx).Create(user) + + tenant := &models.Tenant{ + Name: "Tenant Accept", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + invite := &models.TenantInvite{ + TenantID: tenant.ID, + UserID: owner.ID, + Code: "invite_accept", + Status: string(consts.TenantInviteStatusActive), + MaxUses: 1, + UsedCount: 0, + ExpiresAt: time.Now().Add(24 * time.Hour), + Remark: "测试使用", + } + _ = models.TenantInviteQuery.WithContext(ctx).Create(invite) + + err := Tenant.AcceptInvite(ctx, tenant.ID, user.ID, &tenant_dto.TenantInviteAcceptForm{Code: "invite_accept"}) + So(err, ShouldBeNil) + + exists, err := models.TenantUserQuery.WithContext(ctx). + Where(models.TenantUserQuery.TenantID.Eq(tenant.ID), + models.TenantUserQuery.UserID.Eq(user.ID)). + Exists() + So(err, ShouldBeNil) + So(exists, ShouldBeTrue) + + invite, err = models.TenantInviteQuery.WithContext(ctx).Where(models.TenantInviteQuery.ID.Eq(invite.ID)).First() + So(err, ShouldBeNil) + So(invite.UsedCount, ShouldEqual, 1) + }) +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 661adc5..3ea2e7f 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1637,6 +1637,82 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/creator/members/invite": { + "post": { + "description": "Create an invite for tenant members", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Create member invite", + "parameters": [ + { + "description": "Invite form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantInviteCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TenantInviteItem" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/{id}/review": { + "post": { + "description": "Approve or reject a tenant join request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Review join request", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Join request ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantJoinReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/creator/orders": { "get": { "description": "List sales orders", @@ -2895,6 +2971,121 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/tenants/{id}/invites/accept": { + "post": { + "description": "Accept a tenant invite by code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantPublic" + ], + "summary": "Accept tenant invite", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Invite form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantInviteAcceptForm" + } + } + ], + "responses": { + "200": { + "description": "Accepted", + "schema": { + "type": "string" + } + } + } + } + }, + "/t/{tenantCode}/v1/tenants/{id}/join": { + "post": { + "description": "Submit join request for a tenant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantPublic" + ], + "summary": "Apply to join tenant", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Join form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantJoinApplyForm" + } + } + ], + "responses": { + "200": { + "description": "Applied", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "description": "Cancel join request for a tenant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantPublic" + ], + "summary": "Cancel join request", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Canceled", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/topics": { "get": { "description": "List curated topics", @@ -4624,6 +4815,69 @@ const docTemplate = `{ } } }, + "dto.TenantInviteAcceptForm": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码(必填)。", + "type": "string" + } + } + }, + "dto.TenantInviteCreateForm": { + "type": "object", + "properties": { + "expires_at": { + "description": "ExpiresAt 过期时间(RFC3339,可选,空值使用默认过期时间)。", + "type": "string" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数(\u003c=0 默认 1)。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注说明(可选)。", + "type": "string" + } + } + }, + "dto.TenantInviteItem": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。", + "type": "string" + }, + "id": { + "description": "ID 邀请记录ID。", + "type": "integer" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注说明。", + "type": "string" + }, + "status": { + "description": "Status 邀请状态(active/disabled/expired)。", + "type": "string" + }, + "used_count": { + "description": "UsedCount 已使用次数。", + "type": "integer" + } + } + }, "dto.TenantItem": { "type": "object", "properties": { @@ -4710,6 +4964,28 @@ const docTemplate = `{ } } }, + "dto.TenantJoinApplyForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 申请加入原因(可选,空值会使用默认文案)。", + "type": "string" + } + } + }, + "dto.TenantJoinReviewForm": { + "type": "object", + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string" + }, + "reason": { + "description": "Reason 审核说明(可选,用于展示驳回原因或备注)。", + "type": "string" + } + } + }, "dto.TenantOwnerUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 2f06223..4a5062e 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1631,6 +1631,82 @@ } } }, + "/t/{tenantCode}/v1/creator/members/invite": { + "post": { + "description": "Create an invite for tenant members", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Create member invite", + "parameters": [ + { + "description": "Invite form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantInviteCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.TenantInviteItem" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/{id}/review": { + "post": { + "description": "Approve or reject a tenant join request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Review join request", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Join request ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantJoinReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/creator/orders": { "get": { "description": "List sales orders", @@ -2889,6 +2965,121 @@ } } }, + "/t/{tenantCode}/v1/tenants/{id}/invites/accept": { + "post": { + "description": "Accept a tenant invite by code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantPublic" + ], + "summary": "Accept tenant invite", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Invite form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantInviteAcceptForm" + } + } + ], + "responses": { + "200": { + "description": "Accepted", + "schema": { + "type": "string" + } + } + } + } + }, + "/t/{tenantCode}/v1/tenants/{id}/join": { + "post": { + "description": "Submit join request for a tenant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantPublic" + ], + "summary": "Apply to join tenant", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Join form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.TenantJoinApplyForm" + } + } + ], + "responses": { + "200": { + "description": "Applied", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "description": "Cancel join request for a tenant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantPublic" + ], + "summary": "Cancel join request", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Tenant ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Canceled", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/topics": { "get": { "description": "List curated topics", @@ -4618,6 +4809,69 @@ } } }, + "dto.TenantInviteAcceptForm": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码(必填)。", + "type": "string" + } + } + }, + "dto.TenantInviteCreateForm": { + "type": "object", + "properties": { + "expires_at": { + "description": "ExpiresAt 过期时间(RFC3339,可选,空值使用默认过期时间)。", + "type": "string" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数(\u003c=0 默认 1)。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注说明(可选)。", + "type": "string" + } + } + }, + "dto.TenantInviteItem": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。", + "type": "string" + }, + "id": { + "description": "ID 邀请记录ID。", + "type": "integer" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注说明。", + "type": "string" + }, + "status": { + "description": "Status 邀请状态(active/disabled/expired)。", + "type": "string" + }, + "used_count": { + "description": "UsedCount 已使用次数。", + "type": "integer" + } + } + }, "dto.TenantItem": { "type": "object", "properties": { @@ -4704,6 +4958,28 @@ } } }, + "dto.TenantJoinApplyForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 申请加入原因(可选,空值会使用默认文案)。", + "type": "string" + } + } + }, + "dto.TenantJoinReviewForm": { + "type": "object", + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string" + }, + "reason": { + "description": "Reason 审核说明(可选,用于展示驳回原因或备注)。", + "type": "string" + } + } + }, "dto.TenantOwnerUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 92543f0..81d36db 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1015,6 +1015,51 @@ definitions: required: - duration type: object + dto.TenantInviteAcceptForm: + properties: + code: + description: Code 邀请码(必填)。 + type: string + type: object + dto.TenantInviteCreateForm: + properties: + expires_at: + description: ExpiresAt 过期时间(RFC3339,可选,空值使用默认过期时间)。 + type: string + max_uses: + description: MaxUses 最大可使用次数(<=0 默认 1)。 + type: integer + remark: + description: Remark 备注说明(可选)。 + type: string + type: object + dto.TenantInviteItem: + properties: + code: + description: Code 邀请码。 + type: string + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + expires_at: + description: ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。 + type: string + id: + description: ID 邀请记录ID。 + type: integer + max_uses: + description: MaxUses 最大可使用次数。 + type: integer + remark: + description: Remark 备注说明。 + type: string + status: + description: Status 邀请状态(active/disabled/expired)。 + type: string + used_count: + description: UsedCount 已使用次数。 + type: integer + type: object dto.TenantItem: properties: admin_users: @@ -1074,6 +1119,21 @@ definitions: description: UUID 租户UUID。 type: string type: object + dto.TenantJoinApplyForm: + properties: + reason: + description: Reason 申请加入原因(可选,空值会使用默认文案)。 + type: string + type: object + dto.TenantJoinReviewForm: + properties: + action: + description: Action 审核动作(approve/reject)。 + type: string + reason: + description: Reason 审核说明(可选,用于展示驳回原因或备注)。 + type: string + type: object dto.TenantOwnerUserLite: properties: id: @@ -2662,6 +2722,56 @@ paths: summary: Dashboard stats tags: - CreatorCenter + /t/{tenantCode}/v1/creator/members/{id}/review: + post: + consumes: + - application/json + description: Approve or reject a tenant join request + parameters: + - description: Join request ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Review form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.TenantJoinReviewForm' + produces: + - application/json + responses: + "200": + description: Reviewed + schema: + type: string + summary: Review join request + tags: + - CreatorCenter + /t/{tenantCode}/v1/creator/members/invite: + post: + consumes: + - application/json + description: Create an invite for tenant members + parameters: + - description: Invite form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.TenantInviteCreateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.TenantInviteItem' + summary: Create member invite + tags: + - CreatorCenter /t/{tenantCode}/v1/creator/orders: get: consumes: @@ -3488,6 +3598,83 @@ paths: summary: Follow tenant tags: - TenantPublic + /t/{tenantCode}/v1/tenants/{id}/invites/accept: + post: + consumes: + - application/json + description: Accept a tenant invite by code + parameters: + - description: Tenant ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Invite form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.TenantInviteAcceptForm' + produces: + - application/json + responses: + "200": + description: Accepted + schema: + type: string + summary: Accept tenant invite + tags: + - TenantPublic + /t/{tenantCode}/v1/tenants/{id}/join: + delete: + consumes: + - application/json + description: Cancel join request for a tenant + parameters: + - description: Tenant ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Canceled + schema: + type: string + summary: Cancel join request + tags: + - TenantPublic + post: + consumes: + - application/json + description: Submit join request for a tenant + parameters: + - description: Tenant ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Join form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.TenantJoinApplyForm' + produces: + - application/json + responses: + "200": + description: Applied + schema: + type: string + summary: Apply to join tenant + tags: + - TenantPublic /t/{tenantCode}/v1/topics: get: consumes: diff --git a/docs/todo_list.md b/docs/todo_list.md index 4ea2ab0..1803a93 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -24,6 +24,7 @@ - API - 申请加入:`POST /t/:tenantCode/v1/tenants/:id/join` - 取消申请:`DELETE /t/:tenantCode/v1/tenants/:id/join` + - 接受邀请:`POST /t/:tenantCode/v1/tenants/:id/invites/accept` - 管理员审核:`POST /t/:tenantCode/v1/creator/members/:id/review` - 邀请成员:`POST /t/:tenantCode/v1/creator/members/invite` - DB