diff --git a/backend/app/commands/http/http.go b/backend/app/commands/http/http.go index f1c6508..20b5a18 100644 --- a/backend/app/commands/http/http.go +++ b/backend/app/commands/http/http.go @@ -11,6 +11,7 @@ import ( "quyun/v2/app/http/tenant_join" "quyun/v2/app/http/tenant_media" "quyun/v2/app/http/tenant_public" + "quyun/v2/app/http/web" "quyun/v2/app/jobs" "quyun/v2/app/middlewares" "quyun/v2/app/services" @@ -60,7 +61,7 @@ func Command() atom.Option { tenant_public.Provide, tenant_media.Provide, // {Provider: api.Provide}, - // {Provider: web.Provide}, + web.Provide, ), ), ) diff --git a/backend/app/http/tenant/content_admin.go b/backend/app/http/tenant/content_admin.go index 9dbb99c..9f40e17 100644 --- a/backend/app/http/tenant/content_admin.go +++ b/backend/app/http/tenant/content_admin.go @@ -5,6 +5,7 @@ import ( "quyun/v2/app/errorx" "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" "quyun/v2/app/services" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -28,6 +29,45 @@ func requireTenantAdmin(tenantUser *models.TenantUser) error { return nil } +// list +// +// @Summary 内容列表(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query dto.AdminContentListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.AdminContentItem} +// +// @Router /t/:tenantCode/v1/admin/contents [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind filter query +func (*contentAdmin) list(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, filter *dto.AdminContentListFilter) (*requests.Pager, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if filter == nil { + filter = &dto.AdminContentListFilter{} + } + filter.Pagination.Format() + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "query_user_id": filter.UserID, + "keyword": filter.KeywordTrimmed(), + "status": filter.Status, + "visibility": filter.Visibility, + "published_at_from": filter.PublishedAtFrom, + "published_at_to": filter.PublishedAtTo, + "created_at_from": filter.CreatedAtFrom, + "created_at_to": filter.CreatedAtTo, + }).Info("tenant.admin.contents.list") + + return services.Content.AdminContentPage(ctx.Context(), tenant.ID, filter) +} + // create // // @Summary 创建内容(草稿) diff --git a/backend/app/http/tenant/dto/content_admin_list.go b/backend/app/http/tenant/dto/content_admin_list.go new file mode 100644 index 0000000..283f715 --- /dev/null +++ b/backend/app/http/tenant/dto/content_admin_list.go @@ -0,0 +1,56 @@ +package dto + +import ( + "strings" + "time" + + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen/types" +) + +// AdminContentListFilter 租户管理员查询“内容列表(含草稿/已发布/已下架等)”的过滤条件。 +type AdminContentListFilter struct { + requests.Pagination `json:",inline" query:",inline"` + requests.SortQueryFilter `json:",inline" query:",inline"` + + ID *int64 `json:"id,omitempty" query:"id"` + + UserID *int64 `json:"user_id,omitempty" query:"user_id"` + + Keyword *string `json:"keyword,omitempty" query:"keyword"` + + Status *consts.ContentStatus `json:"status,omitempty" query:"status"` + Visibility *consts.ContentVisibility `json:"visibility,omitempty" query:"visibility"` + + PublishedAtFrom *time.Time `json:"published_at_from,omitempty" query:"published_at_from"` + PublishedAtTo *time.Time `json:"published_at_to,omitempty" query:"published_at_to"` + + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` +} + +func (f *AdminContentListFilter) KeywordTrimmed() string { + if f == nil || f.Keyword == nil { + return "" + } + return strings.TrimSpace(*f.Keyword) +} + +type AdminContentOwnerLite struct { + ID int64 `json:"id"` + Username string `json:"username"` + Status consts.UserStatus `json:"status"` + Roles types.Array[consts.Role] `json:"roles"` +} + +type AdminContentItem struct { + Content *models.Content `json:"content,omitempty"` + Price *models.ContentPrice `json:"price,omitempty"` + Owner *AdminContentOwnerLite `json:"owner,omitempty"` + + StatusDescription string `json:"status_description,omitempty"` + VisibilityDescription string `json:"visibility_description,omitempty"` +} diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 57e53c3..224210a 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -82,6 +82,13 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("contentID"), )) // Register routes for controller: contentAdmin + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/contents -> contentAdmin.list") + router.Get("/t/:tenantCode/v1/admin/contents"[len(r.Path()):], DataFunc3( + r.contentAdmin.list, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Query[dto.AdminContentListFilter]("filter"), + )) r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/contents/:contentID -> contentAdmin.update") router.Patch("/t/:tenantCode/v1/admin/contents/:contentID"[len(r.Path()):], DataFunc4( r.contentAdmin.update, diff --git a/backend/app/http/web/auth.go b/backend/app/http/web/auth.go new file mode 100644 index 0000000..d5fe5b0 --- /dev/null +++ b/backend/app/http/web/auth.go @@ -0,0 +1,69 @@ +package web + +import ( + "quyun/v2/app/errorx" + "quyun/v2/app/http/web/dto" + "quyun/v2/app/services" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type auth struct { + jwt *jwt.JWT +} + +// Login 用户登录(平台侧,非超级管理员)。 +// +// @Summary 用户登录 +// @Tags Web +// @Accept json +// @Produce json +// @Param form body dto.LoginForm true "form" +// @Success 200 {object} dto.LoginResponse "成功" +// @Router /v1/auth/login [post] +// @Bind form body +func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, error) { + m, err := services.User.FindByUsername(ctx, form.Username) + if err != nil { + return nil, errorx.Wrap(err).WithMsg("用户名或密码错误") + } + if ok := m.ComparePassword(ctx, form.Password); !ok { + return nil, errorx.Wrap(errorx.ErrInvalidCredentials).WithMsg("用户名或密码错误") + } + + token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ + UserID: m.ID, + })) + if err != nil { + return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") + } + + return &dto.LoginResponse{Token: token}, nil +} + +// Token 刷新登录凭证。 +// +// @Summary 刷新 Token +// @Tags Web +// @Accept json +// @Produce json +// @Success 200 {object} dto.LoginResponse "成功" +// @Router /v1/auth/token [get] +func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) { + claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) + if !ok || claims == nil || claims.UserID <= 0 { + return nil, errorx.ErrTokenInvalid + } + + token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ + UserID: claims.UserID, + })) + if err != nil { + return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") + } + + return &dto.LoginResponse{Token: token}, nil +} diff --git a/backend/app/http/web/dto/auth.go b/backend/app/http/web/dto/auth.go new file mode 100644 index 0000000..0ab17bc --- /dev/null +++ b/backend/app/http/web/dto/auth.go @@ -0,0 +1,16 @@ +package dto + +// LoginForm 平台侧用户登录表单(用于获取 JWT 访问凭证)。 +// 注意:此登录是“用户身份”登录(非超级管理员),用于进入租户管理后台前的身份校验与租户列表查询。 +type LoginForm struct { + // Username 用户名;必须与数据库 users.username 精确匹配。 + Username string `json:"username,omitempty"` + // Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。 + Password string `json:"password,omitempty"` +} + +// LoginResponse 登录响应。 +type LoginResponse struct { + // Token JWT 访问令牌;前端应以 `Authorization: Bearer ` 方式携带。 + Token string `json:"token"` +} diff --git a/backend/app/http/web/dto/me.go b/backend/app/http/web/dto/me.go new file mode 100644 index 0000000..bb8d895 --- /dev/null +++ b/backend/app/http/web/dto/me.go @@ -0,0 +1,51 @@ +package dto + +import ( + "time" + + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen/types" +) + +// MeResponse 当前登录用户信息(脱敏)。 +type MeResponse struct { + // ID 用户ID(全局唯一)。 + ID int64 `json:"id"` + // Username 用户名。 + Username string `json:"username"` + // Roles 用户全局角色数组(如 user/super_admin 等)。 + Roles types.Array[consts.Role] `json:"roles"` + // Status 用户状态(active/verified/banned 等)。 + Status consts.UserStatus `json:"status"` + // StatusDescription 用户状态描述(便于前端展示)。 + StatusDescription string `json:"status_description"` + // CreatedAt 用户创建时间。 + CreatedAt time.Time `json:"created_at"` + // UpdatedAt 用户更新时间。 + UpdatedAt time.Time `json:"updated_at"` +} + +// MyTenantItem 当前用户可进入的租户条目(用于“选择租户进入后台”页面)。 +type MyTenantItem struct { + // TenantID 租户ID(数值型主键)。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户Code(路由使用:/t/:tenantCode/...)。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // TenantStatus 租户状态(pending/verified/expired 等)。 + TenantStatus consts.TenantStatus `json:"tenant_status"` + // TenantStatusDescription 租户状态描述(便于前端展示)。 + TenantStatusDescription string `json:"tenant_status_description"` + + // IsOwner 是否为租户Owner(tenants.user_id == 当前用户)。 + // 说明:Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。 + IsOwner bool `json:"is_owner"` + // MemberRoles 当前用户在该租户下的角色(tenant_admin/member 等)。 + MemberRoles types.Array[consts.TenantUserRole] `json:"member_roles"` + // MemberStatus 当前用户在该租户下的成员状态。 + MemberStatus consts.UserStatus `json:"member_status"` + // JoinedAt 加入租户时间(tenant_users.created_at)。 + JoinedAt time.Time `json:"joined_at"` +} diff --git a/backend/app/http/web/me.go b/backend/app/http/web/me.go new file mode 100644 index 0000000..628a210 --- /dev/null +++ b/backend/app/http/web/me.go @@ -0,0 +1,61 @@ +package web + +import ( + "quyun/v2/app/errorx" + "quyun/v2/app/http/web/dto" + "quyun/v2/app/services" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type me struct{} + +// Me 获取当前登录用户信息(脱敏)。 +// +// @Summary 当前用户信息 +// @Tags Web +// @Accept json +// @Produce json +// @Success 200 {object} dto.MeResponse "成功" +// @Router /v1/me [get] +func (ctl *me) me(ctx fiber.Ctx) (*dto.MeResponse, error) { + claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) + if !ok || claims == nil || claims.UserID <= 0 { + return nil, errorx.ErrTokenInvalid + } + + m, err := services.User.FindByID(ctx, claims.UserID) + if err != nil { + return nil, err + } + + return &dto.MeResponse{ + ID: m.ID, + Username: m.Username, + Roles: m.Roles, + Status: m.Status, + StatusDescription: m.Status.Description(), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + }, nil +} + +// MyTenants 获取当前用户可进入的租户列表。 +// +// @Summary 我的租户列表 +// @Tags Web +// @Accept json +// @Produce json +// @Success 200 {array} dto.MyTenantItem "成功" +// @Router /v1/me/tenants [get] +func (ctl *me) myTenants(ctx fiber.Ctx) ([]*dto.MyTenantItem, error) { + claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) + if !ok || claims == nil || claims.UserID <= 0 { + return nil, errorx.ErrTokenInvalid + } + + return services.Tenant.UserTenants(ctx, claims.UserID) +} diff --git a/backend/app/http/web/provider.gen.go b/backend/app/http/web/provider.gen.go new file mode 100755 index 0000000..feae2af --- /dev/null +++ b/backend/app/http/web/provider.gen.go @@ -0,0 +1,51 @@ +package web + +import ( + "quyun/v2/app/middlewares" + "quyun/v2/providers/jwt" + + "go.ipao.vip/atom" + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/contracts" + "go.ipao.vip/atom/opt" +) + +func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func( + jwt *jwt.JWT, + ) (*auth, error) { + obj := &auth{ + jwt: jwt, + } + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func() (*me, error) { + obj := &me{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func( + auth *auth, + me *me, + middlewares *middlewares.Middlewares, + ) (contracts.HttpRoute, error) { + obj := &Routes{ + auth: auth, + me: me, + middlewares: middlewares, + } + if err := obj.Prepare(); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupRoutes); err != nil { + return err + } + return nil +} diff --git a/backend/app/http/web/routes.gen.go b/backend/app/http/web/routes.gen.go new file mode 100644 index 0000000..d53446f --- /dev/null +++ b/backend/app/http/web/routes.gen.go @@ -0,0 +1,66 @@ +// Code generated by atomctl. DO NOT EDIT. + +// Package web provides HTTP route definitions and registration +// for the quyun/v2 application. +package web + +import ( + "quyun/v2/app/http/web/dto" + "quyun/v2/app/middlewares" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + _ "go.ipao.vip/atom/contracts" + . "go.ipao.vip/atom/fen" +) + +// Routes implements the HttpRoute contract and provides route registration +// for all controllers in the web module. +// +// @provider contracts.HttpRoute atom.GroupRoutes +type Routes struct { + log *log.Entry `inject:"false"` + middlewares *middlewares.Middlewares + // Controller instances + auth *auth + me *me +} + +// Prepare initializes the routes provider with logging configuration. +func (r *Routes) Prepare() error { + r.log = log.WithField("module", "routes.web") + r.log.Info("Initializing routes module") + return nil +} + +// Name returns the unique identifier for this routes provider. +func (r *Routes) Name() string { + return "web" +} + +// Register registers all HTTP routes with the provided fiber router. +// Each route is registered with its corresponding controller action and parameter bindings. +func (r *Routes) Register(router fiber.Router) { + // Register routes for controller: auth + r.log.Debugf("Registering route: Get /v1/auth/token -> auth.token") + router.Get("/v1/auth/token"[len(r.Path()):], DataFunc0( + r.auth.token, + )) + r.log.Debugf("Registering route: Post /v1/auth/login -> auth.login") + router.Post("/v1/auth/login"[len(r.Path()):], DataFunc1( + r.auth.login, + Body[dto.LoginForm]("form"), + )) + // Register routes for controller: me + r.log.Debugf("Registering route: Get /v1/me -> me.me") + router.Get("/v1/me"[len(r.Path()):], DataFunc0( + r.me.me, + )) + r.log.Debugf("Registering route: Get /v1/me/tenants -> me.myTenants") + router.Get("/v1/me/tenants"[len(r.Path()):], DataFunc0( + r.me.myTenants, + )) + + r.log.Info("Successfully registered all routes") +} diff --git a/backend/app/http/web/routes.manual.go b/backend/app/http/web/routes.manual.go new file mode 100644 index 0000000..ec69362 --- /dev/null +++ b/backend/app/http/web/routes.manual.go @@ -0,0 +1,11 @@ +package web + +func (r *Routes) Path() string { + return "/v1" +} + +func (r *Routes) Middlewares() []any { + return []any{ + r.middlewares.UserAuth, + } +} diff --git a/backend/app/middlewares/user.go b/backend/app/middlewares/user.go new file mode 100644 index 0000000..b6b87ff --- /dev/null +++ b/backend/app/middlewares/user.go @@ -0,0 +1,57 @@ +package middlewares + +import ( + "strings" + + "quyun/v2/app/errorx" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" +) + +func shouldSkipUserJWTAuth(path string) bool { + // 登录接口无需鉴权。 + if strings.Contains(path, "/v1/auth/login") { + return true + } + return false +} + +// UserAuth 为平台通用(非租户域)接口提供 JWT 校验,并写入 claims 到 ctx locals。 +func (f *Middlewares) UserAuth(c fiber.Ctx) error { + if shouldSkipUserJWTAuth(c.Path()) { + f.log.Debug("middlewares.user.auth.skipped") + return c.Next() + } + + authHeader := c.Get(jwt.HttpHeader) + if authHeader == "" { + f.log.Info("middlewares.user.auth.missing_token") + return errorx.ErrTokenMissing + } + + claims, err := f.jwt.Parse(authHeader) + if err != nil { + f.log.WithError(err).Warn("middlewares.user.auth.invalid_token") + switch err { + case jwt.TokenExpired: + return errorx.ErrTokenExpired + case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid: + return errorx.ErrTokenInvalid + default: + return errorx.ErrTokenInvalid + } + } + if claims.UserID == 0 { + f.log.Warn("middlewares.user.auth.missing_user_id") + return errorx.ErrTokenInvalid + } + + f.log.WithFields(map[string]any{ + "user_id": claims.UserID, + }).Info("middlewares.user.auth.ok") + + c.Locals(consts.CtxKeyClaims, claims) + return c.Next() +} diff --git a/backend/app/services/content_admin.go b/backend/app/services/content_admin.go new file mode 100644 index 0000000..18338e0 --- /dev/null +++ b/backend/app/services/content_admin.go @@ -0,0 +1,180 @@ +package services + +import ( + "context" + "strings" + + tenantdto "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/database" + "quyun/v2/database/models" + + "github.com/pkg/errors" + "github.com/samber/lo" + log "github.com/sirupsen/logrus" + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" +) + +// AdminContentPage returns contents list for tenant admin (includes drafts/unpublished/etc). +func (s *content) AdminContentPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminContentListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errors.New("tenant_id must be > 0") + } + if filter == nil { + filter = &tenantdto.AdminContentListFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": tenantID, + "page": filter.Page, + "limit": filter.Limit, + "user_id": lo.FromPtr(filter.UserID), + "keyword": filter.KeywordTrimmed(), + "status": lo.FromPtr(filter.Status), + }).Info("services.content.admin.page") + + filter.Pagination.Format() + + cTbl, query := models.ContentQuery.QueryContext(ctx) + query = query.Select(cTbl.ALL) + + conds := []gen.Condition{ + cTbl.TenantID.Eq(tenantID), + cTbl.DeletedAt.IsNull(), + } + if filter.ID != nil && *filter.ID > 0 { + conds = append(conds, cTbl.ID.Eq(*filter.ID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, cTbl.UserID.Eq(*filter.UserID)) + } + if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" { + conds = append(conds, cTbl.Title.Like(database.WrapLike(kw))) + } + if filter.Status != nil { + conds = append(conds, cTbl.Status.Eq(*filter.Status)) + } + if filter.Visibility != nil { + conds = append(conds, cTbl.Visibility.Eq(*filter.Visibility)) + } + if filter.PublishedAtFrom != nil { + conds = append(conds, cTbl.PublishedAt.Gte(*filter.PublishedAtFrom)) + } + if filter.PublishedAtTo != nil { + conds = append(conds, cTbl.PublishedAt.Lte(*filter.PublishedAtTo)) + } + if filter.CreatedAtFrom != nil { + conds = append(conds, cTbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, cTbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + + orderBys := make([]field.Expr, 0, 6) + allowedAsc := map[string]field.Expr{ + "id": cTbl.ID.Asc(), + "title": cTbl.Title.Asc(), + "user_id": cTbl.UserID.Asc(), + "status": cTbl.Status.Asc(), + "visibility": cTbl.Visibility.Asc(), + "published_at": cTbl.PublishedAt.Asc(), + "created_at": cTbl.CreatedAt.Asc(), + "updated_at": cTbl.UpdatedAt.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": cTbl.ID.Desc(), + "title": cTbl.Title.Desc(), + "user_id": cTbl.UserID.Desc(), + "status": cTbl.Status.Desc(), + "visibility": cTbl.Visibility.Desc(), + "published_at": cTbl.PublishedAt.Desc(), + "created_at": cTbl.CreatedAt.Desc(), + "updated_at": cTbl.UpdatedAt.Desc(), + } + for _, f := range filter.AscFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedAsc[f]; ok { + orderBys = append(orderBys, ob) + } + } + for _, f := range filter.DescFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedDesc[f]; ok { + orderBys = append(orderBys, ob) + } + } + if len(orderBys) == 0 { + orderBys = append(orderBys, cTbl.ID.Desc()) + } else { + orderBys = append(orderBys, cTbl.ID.Desc()) + } + + items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + contentIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { + if item == nil || item.ID <= 0 { + return 0, false + } + return item.ID, true + })) + ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { + if item == nil || item.UserID <= 0 { + return 0, false + } + return item.UserID, true + })) + + priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) + if err != nil { + return nil, err + } + + ownerMap := map[int64]*tenantdto.AdminContentOwnerLite{} + if len(ownerIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find() + if err != nil { + return nil, err + } + for _, u := range users { + if u == nil { + continue + } + ownerMap[u.ID] = &tenantdto.AdminContentOwnerLite{ + ID: u.ID, + Username: u.Username, + Status: u.Status, + Roles: u.Roles, + } + } + } + + respItems := lo.Map(items, func(model *models.Content, _ int) *tenantdto.AdminContentItem { + if model == nil { + return nil + } + return &tenantdto.AdminContentItem{ + Content: model, + Price: priceByContent[model.ID], + Owner: ownerMap[model.UserID], + StatusDescription: model.Status.Description(), + VisibilityDescription: model.Visibility.Description(), + } + }) + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: respItems, + }, nil +} diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index 063db3c..9bbf652 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -7,6 +7,7 @@ import ( superdto "quyun/v2/app/http/super/dto" tenantdto "quyun/v2/app/http/tenant/dto" + web_dto "quyun/v2/app/http/web/dto" "quyun/v2/app/requests" "quyun/v2/database" "quyun/v2/database/models" @@ -26,6 +27,72 @@ import ( // @provider type tenant struct{} +// UserTenants 查询“当前用户可进入的租户列表”(平台通用域 /v1)。 +// - 返回 tenant_users 维度的角色与加入时间,便于前端做“选择租户进入后台”的交互。 +// - 不做租户状态过滤:前端可以根据 TenantStatusDescription 做提示或禁用进入按钮。 +func (t *tenant) UserTenants(ctx context.Context, userID int64) ([]*web_dto.MyTenantItem, error) { + if userID <= 0 { + return nil, errors.New("user_id must be > 0") + } + + // 先查 tenant_users,拿到用户加入的租户ID与角色信息(避免 join scan 冲突/字段覆盖问题)。 + tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx) + tenantUsers, err := tuQuery.Where(tuTbl.UserID.Eq(userID)).Order(tuTbl.ID.Desc()).Find() + if err != nil { + return nil, err + } + if len(tenantUsers) == 0 { + return []*web_dto.MyTenantItem{}, nil + } + + tenantIDs := lo.Uniq(lo.FilterMap(tenantUsers, func(tu *models.TenantUser, _ int) (int64, bool) { + if tu == nil || tu.TenantID <= 0 { + return 0, false + } + return tu.TenantID, true + })) + if len(tenantIDs) == 0 { + return []*web_dto.MyTenantItem{}, nil + } + + // 再查 tenants(批量),并构建映射,保持输出顺序以 tenant_users 为准。 + teTbl, teQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := teQuery.Where(teTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, err + } + tenantMap := make(map[int64]*models.Tenant, len(tenants)) + for _, te := range tenants { + if te == nil { + continue + } + tenantMap[te.ID] = te + } + + items := make([]*web_dto.MyTenantItem, 0, len(tenantUsers)) + for _, tu := range tenantUsers { + if tu == nil { + continue + } + te := tenantMap[tu.TenantID] + if te == nil { + continue + } + items = append(items, &web_dto.MyTenantItem{ + TenantID: te.ID, + TenantCode: te.Code, + TenantName: te.Name, + TenantStatus: te.Status, + TenantStatusDescription: te.Status.Description(), + IsOwner: te.UserID == userID, + MemberRoles: tu.Role, + MemberStatus: tu.Status, + JoinedAt: tu.CreatedAt, + }) + } + return items, nil +} + // SuperDetail 查询单个租户详情(平台侧)。 func (t *tenant) SuperDetail(ctx context.Context, tenantID int64) (*superdto.TenantItem, error) { if tenantID <= 0 { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index a75d4f6..46603ee 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -42,7 +42,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/dto.LoginForm" + "$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginForm" } } ], @@ -50,7 +50,7 @@ const docTemplate = `{ "200": { "description": "成功", "schema": { - "$ref": "#/definitions/dto.LoginResponse" + "$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse" } } } @@ -71,7 +71,7 @@ const docTemplate = `{ "200": { "description": "成功", "schema": { - "$ref": "#/definitions/dto.LoginResponse" + "$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse" } } } @@ -1429,6 +1429,140 @@ const docTemplate = `{ } }, "/t/{tenantCode}/v1/admin/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "内容列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, + { + "type": "string", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "name": "created_at_to", + "in": "query" + }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "published_at_from", + "in": "query" + }, + { + "type": "string", + "name": "published_at_to", + "in": "query" + }, + { + "enum": [ + "draft", + "reviewing", + "published", + "unpublished", + "blocked" + ], + "type": "string", + "x-enum-varnames": [ + "ContentStatusDraft", + "ContentStatusReviewing", + "ContentStatusPublished", + "ContentStatusUnpublished", + "ContentStatusBlocked" + ], + "name": "status", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "enum": [ + "public", + "tenant_only", + "private" + ], + "type": "string", + "x-enum-varnames": [ + "ContentVisibilityPublic", + "ContentVisibilityTenantOnly", + "ContentVisibilityPrivate" + ], + "name": "visibility", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.AdminContentItem" + } + } + } + ] + } + } + } + }, "post": { "consumes": [ "application/json" @@ -3295,7 +3429,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/dto.MeResponse" + "$ref": "#/definitions/quyun_v2_app_http_tenant_dto.MeResponse" } } } @@ -3717,6 +3851,108 @@ const docTemplate = `{ } } } + }, + "/v1/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "用户登录", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse" + } + } + } + } + }, + "/v1/auth/token": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "刷新 Token", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse" + } + } + } + } + }, + "/v1/me": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "当前用户信息", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.MeResponse" + } + } + } + } + }, + "/v1/me/tenants": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "我的租户列表", + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.MyTenantItem" + } + } + } + } + } } }, "definitions": { @@ -3958,6 +4194,46 @@ const docTemplate = `{ "UserStatusBanned" ] }, + "dto.AdminContentItem": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/models.Content" + }, + "owner": { + "$ref": "#/definitions/dto.AdminContentOwnerLite" + }, + "price": { + "$ref": "#/definitions/models.ContentPrice" + }, + "status_description": { + "type": "string" + }, + "visibility_description": { + "type": "string" + } + } + }, + "dto.AdminContentOwnerLite": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/consts.Role" + } + }, + "status": { + "$ref": "#/definitions/consts.UserStatus" + }, + "username": { + "type": "string" + } + } + }, "dto.AdminLedgerItem": { "type": "object", "properties": { @@ -4435,25 +4711,6 @@ const docTemplate = `{ } } }, - "dto.LoginForm": { - "type": "object", - "properties": { - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "dto.LoginResponse": { - "type": "object", - "properties": { - "token": { - "type": "string" - } - } - }, "dto.MeBalanceResponse": { "type": "object", "properties": { @@ -4479,35 +4736,6 @@ const docTemplate = `{ } } }, - "dto.MeResponse": { - "type": "object", - "properties": { - "tenant": { - "description": "Tenant is the resolved tenant by ` + "`" + `tenantCode` + "`" + `.", - "allOf": [ - { - "$ref": "#/definitions/models.Tenant" - } - ] - }, - "tenant_user": { - "description": "TenantUser is the membership record of the authenticated user within the tenant.", - "allOf": [ - { - "$ref": "#/definitions/models.TenantUser" - } - ] - }, - "user": { - "description": "User is the authenticated user derived from JWT ` + "`" + `user_id` + "`" + `.", - "allOf": [ - { - "$ref": "#/definitions/models.User" - } - ] - } - } - }, "dto.MyLedgerItem": { "type": "object", "properties": { @@ -4525,6 +4753,58 @@ const docTemplate = `{ } } }, + "dto.MyTenantItem": { + "type": "object", + "properties": { + "is_owner": { + "description": "IsOwner 是否为租户Owner(tenants.user_id == 当前用户)。\n说明:Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。", + "type": "boolean" + }, + "joined_at": { + "description": "JoinedAt 加入租户时间(tenant_users.created_at)。", + "type": "string" + }, + "member_roles": { + "description": "MemberRoles 当前用户在该租户下的角色(tenant_admin/member 等)。", + "type": "array", + "items": { + "$ref": "#/definitions/consts.TenantUserRole" + } + }, + "member_status": { + "description": "MemberStatus 当前用户在该租户下的成员状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserStatus" + } + ] + }, + "tenant_code": { + "description": "TenantCode 租户Code(路由使用:/t/:tenantCode/...)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(数值型主键)。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "tenant_status": { + "description": "TenantStatus 租户状态(pending/verified/expired 等)。", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantStatus" + } + ] + }, + "tenant_status_description": { + "description": "TenantStatusDescription 租户状态描述(便于前端展示)。", + "type": "string" + } + } + }, "dto.OrderBuyerLite": { "type": "object", "properties": { @@ -5899,6 +6179,116 @@ const docTemplate = `{ } } }, + "quyun_v2_app_http_super_dto.LoginForm": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "quyun_v2_app_http_super_dto.LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "quyun_v2_app_http_tenant_dto.MeResponse": { + "type": "object", + "properties": { + "tenant": { + "description": "Tenant is the resolved tenant by ` + "`" + `tenantCode` + "`" + `.", + "allOf": [ + { + "$ref": "#/definitions/models.Tenant" + } + ] + }, + "tenant_user": { + "description": "TenantUser is the membership record of the authenticated user within the tenant.", + "allOf": [ + { + "$ref": "#/definitions/models.TenantUser" + } + ] + }, + "user": { + "description": "User is the authenticated user derived from JWT ` + "`" + `user_id` + "`" + `.", + "allOf": [ + { + "$ref": "#/definitions/models.User" + } + ] + } + } + }, + "quyun_v2_app_http_web_dto.LoginForm": { + "type": "object", + "properties": { + "password": { + "description": "Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。", + "type": "string" + }, + "username": { + "description": "Username 用户名;必须与数据库 users.username 精确匹配。", + "type": "string" + } + } + }, + "quyun_v2_app_http_web_dto.LoginResponse": { + "type": "object", + "properties": { + "token": { + "description": "Token JWT 访问令牌;前端应以 ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + ` 方式携带。", + "type": "string" + } + } + }, + "quyun_v2_app_http_web_dto.MeResponse": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 用户创建时间。", + "type": "string" + }, + "id": { + "description": "ID 用户ID(全局唯一)。", + "type": "integer" + }, + "roles": { + "description": "Roles 用户全局角色数组(如 user/super_admin 等)。", + "type": "array", + "items": { + "$ref": "#/definitions/consts.Role" + } + }, + "status": { + "description": "Status 用户状态(active/verified/banned 等)。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 用户状态描述(便于前端展示)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 用户更新时间。", + "type": "string" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "requests.KV": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index ed961f0..be40fe2 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -36,7 +36,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/dto.LoginForm" + "$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginForm" } } ], @@ -44,7 +44,7 @@ "200": { "description": "成功", "schema": { - "$ref": "#/definitions/dto.LoginResponse" + "$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse" } } } @@ -65,7 +65,7 @@ "200": { "description": "成功", "schema": { - "$ref": "#/definitions/dto.LoginResponse" + "$ref": "#/definitions/quyun_v2_app_http_super_dto.LoginResponse" } } } @@ -1423,6 +1423,140 @@ } }, "/t/{tenantCode}/v1/admin/contents": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "内容列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Asc specifies comma-separated field names to sort ascending by.", + "name": "asc", + "in": "query" + }, + { + "type": "string", + "name": "created_at_from", + "in": "query" + }, + { + "type": "string", + "name": "created_at_to", + "in": "query" + }, + { + "type": "string", + "description": "Desc specifies comma-separated field names to sort descending by.", + "name": "desc", + "in": "query" + }, + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "published_at_from", + "in": "query" + }, + { + "type": "string", + "name": "published_at_to", + "in": "query" + }, + { + "enum": [ + "draft", + "reviewing", + "published", + "unpublished", + "blocked" + ], + "type": "string", + "x-enum-varnames": [ + "ContentStatusDraft", + "ContentStatusReviewing", + "ContentStatusPublished", + "ContentStatusUnpublished", + "ContentStatusBlocked" + ], + "name": "status", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "enum": [ + "public", + "tenant_only", + "private" + ], + "type": "string", + "x-enum-varnames": [ + "ContentVisibilityPublic", + "ContentVisibilityTenantOnly", + "ContentVisibilityPrivate" + ], + "name": "visibility", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/dto.AdminContentItem" + } + } + } + ] + } + } + } + }, "post": { "consumes": [ "application/json" @@ -3289,7 +3423,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/dto.MeResponse" + "$ref": "#/definitions/quyun_v2_app_http_tenant_dto.MeResponse" } } } @@ -3711,6 +3845,108 @@ } } } + }, + "/v1/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "用户登录", + "parameters": [ + { + "description": "form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginForm" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse" + } + } + } + } + }, + "/v1/auth/token": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "刷新 Token", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.LoginResponse" + } + } + } + } + }, + "/v1/me": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "当前用户信息", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/quyun_v2_app_http_web_dto.MeResponse" + } + } + } + } + }, + "/v1/me/tenants": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Web" + ], + "summary": "我的租户列表", + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.MyTenantItem" + } + } + } + } + } } }, "definitions": { @@ -3952,6 +4188,46 @@ "UserStatusBanned" ] }, + "dto.AdminContentItem": { + "type": "object", + "properties": { + "content": { + "$ref": "#/definitions/models.Content" + }, + "owner": { + "$ref": "#/definitions/dto.AdminContentOwnerLite" + }, + "price": { + "$ref": "#/definitions/models.ContentPrice" + }, + "status_description": { + "type": "string" + }, + "visibility_description": { + "type": "string" + } + } + }, + "dto.AdminContentOwnerLite": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/consts.Role" + } + }, + "status": { + "$ref": "#/definitions/consts.UserStatus" + }, + "username": { + "type": "string" + } + } + }, "dto.AdminLedgerItem": { "type": "object", "properties": { @@ -4429,25 +4705,6 @@ } } }, - "dto.LoginForm": { - "type": "object", - "properties": { - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "dto.LoginResponse": { - "type": "object", - "properties": { - "token": { - "type": "string" - } - } - }, "dto.MeBalanceResponse": { "type": "object", "properties": { @@ -4473,35 +4730,6 @@ } } }, - "dto.MeResponse": { - "type": "object", - "properties": { - "tenant": { - "description": "Tenant is the resolved tenant by `tenantCode`.", - "allOf": [ - { - "$ref": "#/definitions/models.Tenant" - } - ] - }, - "tenant_user": { - "description": "TenantUser is the membership record of the authenticated user within the tenant.", - "allOf": [ - { - "$ref": "#/definitions/models.TenantUser" - } - ] - }, - "user": { - "description": "User is the authenticated user derived from JWT `user_id`.", - "allOf": [ - { - "$ref": "#/definitions/models.User" - } - ] - } - } - }, "dto.MyLedgerItem": { "type": "object", "properties": { @@ -4519,6 +4747,58 @@ } } }, + "dto.MyTenantItem": { + "type": "object", + "properties": { + "is_owner": { + "description": "IsOwner 是否为租户Owner(tenants.user_id == 当前用户)。\n说明:Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。", + "type": "boolean" + }, + "joined_at": { + "description": "JoinedAt 加入租户时间(tenant_users.created_at)。", + "type": "string" + }, + "member_roles": { + "description": "MemberRoles 当前用户在该租户下的角色(tenant_admin/member 等)。", + "type": "array", + "items": { + "$ref": "#/definitions/consts.TenantUserRole" + } + }, + "member_status": { + "description": "MemberStatus 当前用户在该租户下的成员状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserStatus" + } + ] + }, + "tenant_code": { + "description": "TenantCode 租户Code(路由使用:/t/:tenantCode/...)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(数值型主键)。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "tenant_status": { + "description": "TenantStatus 租户状态(pending/verified/expired 等)。", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantStatus" + } + ] + }, + "tenant_status_description": { + "description": "TenantStatusDescription 租户状态描述(便于前端展示)。", + "type": "string" + } + } + }, "dto.OrderBuyerLite": { "type": "object", "properties": { @@ -5893,6 +6173,116 @@ } } }, + "quyun_v2_app_http_super_dto.LoginForm": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "quyun_v2_app_http_super_dto.LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "quyun_v2_app_http_tenant_dto.MeResponse": { + "type": "object", + "properties": { + "tenant": { + "description": "Tenant is the resolved tenant by `tenantCode`.", + "allOf": [ + { + "$ref": "#/definitions/models.Tenant" + } + ] + }, + "tenant_user": { + "description": "TenantUser is the membership record of the authenticated user within the tenant.", + "allOf": [ + { + "$ref": "#/definitions/models.TenantUser" + } + ] + }, + "user": { + "description": "User is the authenticated user derived from JWT `user_id`.", + "allOf": [ + { + "$ref": "#/definitions/models.User" + } + ] + } + } + }, + "quyun_v2_app_http_web_dto.LoginForm": { + "type": "object", + "properties": { + "password": { + "description": "Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。", + "type": "string" + }, + "username": { + "description": "Username 用户名;必须与数据库 users.username 精确匹配。", + "type": "string" + } + } + }, + "quyun_v2_app_http_web_dto.LoginResponse": { + "type": "object", + "properties": { + "token": { + "description": "Token JWT 访问令牌;前端应以 `Authorization: Bearer \u003ctoken\u003e` 方式携带。", + "type": "string" + } + } + }, + "quyun_v2_app_http_web_dto.MeResponse": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 用户创建时间。", + "type": "string" + }, + "id": { + "description": "ID 用户ID(全局唯一)。", + "type": "integer" + }, + "roles": { + "description": "Roles 用户全局角色数组(如 user/super_admin 等)。", + "type": "array", + "items": { + "$ref": "#/definitions/consts.Role" + } + }, + "status": { + "description": "Status 用户状态(active/verified/banned 等)。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 用户状态描述(便于前端展示)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 用户更新时间。", + "type": "string" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "requests.KV": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index cfd63cf..d3349dd 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -184,6 +184,32 @@ definitions: - UserStatusPendingVerify - UserStatusVerified - UserStatusBanned + dto.AdminContentItem: + properties: + content: + $ref: '#/definitions/models.Content' + owner: + $ref: '#/definitions/dto.AdminContentOwnerLite' + price: + $ref: '#/definitions/models.ContentPrice' + status_description: + type: string + visibility_description: + type: string + type: object + dto.AdminContentOwnerLite: + properties: + id: + type: integer + roles: + items: + $ref: '#/definitions/consts.Role' + type: array + status: + $ref: '#/definitions/consts.UserStatus' + username: + type: string + type: object dto.AdminLedgerItem: properties: ledger: @@ -531,18 +557,6 @@ definitions: description: Reason 申请原因(可选):用于向租户管理员说明申请加入的目的。 type: string type: object - dto.LoginForm: - properties: - password: - type: string - username: - type: string - type: object - dto.LoginResponse: - properties: - token: - type: string - type: object dto.MeBalanceResponse: properties: balance: @@ -559,22 +573,6 @@ definitions: description: UpdatedAt 更新时间:余额变更时更新。 type: string type: object - dto.MeResponse: - properties: - tenant: - allOf: - - $ref: '#/definitions/models.Tenant' - description: Tenant is the resolved tenant by `tenantCode`. - tenant_user: - allOf: - - $ref: '#/definitions/models.TenantUser' - description: TenantUser is the membership record of the authenticated user - within the tenant. - user: - allOf: - - $ref: '#/definitions/models.User' - description: User is the authenticated user derived from JWT `user_id`. - type: object dto.MyLedgerItem: properties: ledger: @@ -585,6 +583,42 @@ definitions: description: TypeDescription 流水类型中文说明(用于前端展示)。 type: string type: object + dto.MyTenantItem: + properties: + is_owner: + description: |- + IsOwner 是否为租户Owner(tenants.user_id == 当前用户)。 + 说明:Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。 + type: boolean + joined_at: + description: JoinedAt 加入租户时间(tenant_users.created_at)。 + type: string + member_roles: + description: MemberRoles 当前用户在该租户下的角色(tenant_admin/member 等)。 + items: + $ref: '#/definitions/consts.TenantUserRole' + type: array + member_status: + allOf: + - $ref: '#/definitions/consts.UserStatus' + description: MemberStatus 当前用户在该租户下的成员状态。 + tenant_code: + description: TenantCode 租户Code(路由使用:/t/:tenantCode/...)。 + type: string + tenant_id: + description: TenantID 租户ID(数值型主键)。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + tenant_status: + allOf: + - $ref: '#/definitions/consts.TenantStatus' + description: TenantStatus 租户状态(pending/verified/expired 等)。 + tenant_status_description: + description: TenantStatusDescription 租户状态描述(便于前端展示)。 + type: string + type: object dto.OrderBuyerLite: properties: id: @@ -1503,6 +1537,76 @@ definitions: verified_at: type: string type: object + quyun_v2_app_http_super_dto.LoginForm: + properties: + password: + type: string + username: + type: string + type: object + quyun_v2_app_http_super_dto.LoginResponse: + properties: + token: + type: string + type: object + quyun_v2_app_http_tenant_dto.MeResponse: + properties: + tenant: + allOf: + - $ref: '#/definitions/models.Tenant' + description: Tenant is the resolved tenant by `tenantCode`. + tenant_user: + allOf: + - $ref: '#/definitions/models.TenantUser' + description: TenantUser is the membership record of the authenticated user + within the tenant. + user: + allOf: + - $ref: '#/definitions/models.User' + description: User is the authenticated user derived from JWT `user_id`. + type: object + quyun_v2_app_http_web_dto.LoginForm: + properties: + password: + description: Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。 + type: string + username: + description: Username 用户名;必须与数据库 users.username 精确匹配。 + type: string + type: object + quyun_v2_app_http_web_dto.LoginResponse: + properties: + token: + description: 'Token JWT 访问令牌;前端应以 `Authorization: Bearer ` 方式携带。' + type: string + type: object + quyun_v2_app_http_web_dto.MeResponse: + properties: + created_at: + description: CreatedAt 用户创建时间。 + type: string + id: + description: ID 用户ID(全局唯一)。 + type: integer + roles: + description: Roles 用户全局角色数组(如 user/super_admin 等)。 + items: + $ref: '#/definitions/consts.Role' + type: array + status: + allOf: + - $ref: '#/definitions/consts.UserStatus' + description: Status 用户状态(active/verified/banned 等)。 + status_description: + description: StatusDescription 用户状态描述(便于前端展示)。 + type: string + updated_at: + description: UpdatedAt 用户更新时间。 + type: string + username: + description: Username 用户名。 + type: string + type: object requests.KV: properties: key: @@ -1558,14 +1662,14 @@ paths: name: form required: true schema: - $ref: '#/definitions/dto.LoginForm' + $ref: '#/definitions/quyun_v2_app_http_super_dto.LoginForm' produces: - application/json responses: "200": description: 成功 schema: - $ref: '#/definitions/dto.LoginResponse' + $ref: '#/definitions/quyun_v2_app_http_super_dto.LoginResponse' tags: - Super /super/v1/auth/token: @@ -1578,7 +1682,7 @@ paths: "200": description: 成功 schema: - $ref: '#/definitions/dto.LoginResponse' + $ref: '#/definitions/quyun_v2_app_http_super_dto.LoginResponse' tags: - Super /super/v1/contents: @@ -2465,6 +2569,95 @@ paths: tags: - Super /t/{tenantCode}/v1/admin/contents: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Asc specifies comma-separated field names to sort ascending by. + in: query + name: asc + type: string + - in: query + name: created_at_from + type: string + - in: query + name: created_at_to + type: string + - description: Desc specifies comma-separated field names to sort descending + by. + in: query + name: desc + type: string + - in: query + name: id + type: integer + - in: query + name: keyword + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - in: query + name: published_at_from + type: string + - in: query + name: published_at_to + type: string + - enum: + - draft + - reviewing + - published + - unpublished + - blocked + in: query + name: status + type: string + x-enum-varnames: + - ContentStatusDraft + - ContentStatusReviewing + - ContentStatusPublished + - ContentStatusUnpublished + - ContentStatusBlocked + - in: query + name: user_id + type: integer + - enum: + - public + - tenant_only + - private + in: query + name: visibility + type: string + x-enum-varnames: + - ContentVisibilityPublic + - ContentVisibilityTenantOnly + - ContentVisibilityPrivate + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/dto.AdminContentItem' + type: object + summary: 内容列表(租户管理) + tags: + - Tenant post: consumes: - application/json @@ -3717,7 +3910,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/dto.MeResponse' + $ref: '#/definitions/quyun_v2_app_http_tenant_dto.MeResponse' summary: 当前租户上下文信息 tags: - Tenant @@ -3994,6 +4187,71 @@ paths: summary: 获取公开试看资源(preview role) tags: - TenantPublic + /v1/auth/login: + post: + consumes: + - application/json + parameters: + - description: form + in: body + name: form + required: true + schema: + $ref: '#/definitions/quyun_v2_app_http_web_dto.LoginForm' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/quyun_v2_app_http_web_dto.LoginResponse' + summary: 用户登录 + tags: + - Web + /v1/auth/token: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/quyun_v2_app_http_web_dto.LoginResponse' + summary: 刷新 Token + tags: + - Web + /v1/me: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/quyun_v2_app_http_web_dto.MeResponse' + summary: 当前用户信息 + tags: + - Web + /v1/me/tenants: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: 成功 + schema: + items: + $ref: '#/definitions/dto.MyTenantItem' + type: array + summary: 我的租户列表 + tags: + - Web securityDefinitions: BasicAuth: type: basic diff --git a/frontend/tenant_admin/src/layout/AppMenu.vue b/frontend/tenant_admin/src/layout/AppMenu.vue index 4ae8015..eeb37b0 100644 --- a/frontend/tenant_admin/src/layout/AppMenu.vue +++ b/frontend/tenant_admin/src/layout/AppMenu.vue @@ -24,7 +24,11 @@ const model = computed(() => { items: [ { label: '概览', icon: 'pi pi-fw pi-home', to: { name: 'tenantadmin-dashboard', params: { tenantCode: code } } }, { label: '入驻申请', icon: 'pi pi-fw pi-inbox', to: { name: 'tenantadmin-join-requests', params: { tenantCode: code } } }, - { label: '成员管理', icon: 'pi pi-fw pi-users', to: { name: 'tenantadmin-members', params: { tenantCode: code } } } + { label: '成员管理', icon: 'pi pi-fw pi-users', to: { name: 'tenantadmin-members', params: { tenantCode: code } } }, + { label: '内容管理', icon: 'pi pi-fw pi-file', to: { name: 'tenantadmin-contents', params: { tenantCode: code } } }, + { label: '订单与退款', icon: 'pi pi-fw pi-shopping-cart', to: { name: 'tenantadmin-orders', params: { tenantCode: code } } }, + { label: '邀请码', icon: 'pi pi-fw pi-ticket', to: { name: 'tenantadmin-invites', params: { tenantCode: code } } }, + { label: '财务流水', icon: 'pi pi-fw pi-wallet', to: { name: 'tenantadmin-ledgers', params: { tenantCode: code } } } ] } ]; diff --git a/frontend/tenant_admin/src/router/index.js b/frontend/tenant_admin/src/router/index.js index 5b619df..6c1d817 100644 --- a/frontend/tenant_admin/src/router/index.js +++ b/frontend/tenant_admin/src/router/index.js @@ -28,6 +28,31 @@ const router = createRouter({ path: 'members', name: 'tenantadmin-members', component: () => import('@/views/tenantadmin/Members.vue') + }, + { + path: 'contents', + name: 'tenantadmin-contents', + component: () => import('@/views/tenantadmin/Contents.vue') + }, + { + path: 'orders', + name: 'tenantadmin-orders', + component: () => import('@/views/tenantadmin/Orders.vue') + }, + { + path: 'orders/:orderID', + name: 'tenantadmin-order-detail', + component: () => import('@/views/tenantadmin/OrderDetail.vue') + }, + { + path: 'invites', + name: 'tenantadmin-invites', + component: () => import('@/views/tenantadmin/Invites.vue') + }, + { + path: 'ledgers', + name: 'tenantadmin-ledgers', + component: () => import('@/views/tenantadmin/Ledgers.vue') } ] }, @@ -40,6 +65,8 @@ const router = createRouter({ router.beforeEach((to) => { if (to.meta?.requiresAuth !== true) return true; + const token = String(localStorage.getItem('token') || '').trim(); + if (!token) return { name: 'tenantadmin-enter' }; const tenantCode = String(to.params?.tenantCode ?? '').trim(); if (!tenantCode) return { name: 'tenantadmin-enter' }; return true; diff --git a/frontend/tenant_admin/src/service/TenantAdminService.js b/frontend/tenant_admin/src/service/TenantAdminService.js index 12d02b3..c476030 100644 --- a/frontend/tenant_admin/src/service/TenantAdminService.js +++ b/frontend/tenant_admin/src/service/TenantAdminService.js @@ -7,6 +7,25 @@ function normalizeItems(items) { } export const TenantAdminService = { + async login({ username, password } = {}) { + const data = await requestJson('/v1/auth/login', { + method: 'POST', + body: { username, password } + }); + return { token: data?.token || '' }; + }, + async refreshToken() { + const data = await requestJson('/v1/auth/token'); + return { token: data?.token || '' }; + }, + async getMeGlobal() { + return requestJson('/v1/me'); + }, + async listMyTenants() { + const data = await requestJson('/v1/me/tenants'); + if (Array.isArray(data)) return data; + return normalizeItems(data); + }, async getMe(tenantCode) { return requestJson(tenantApiPath(tenantCode, '/me')); }, @@ -61,6 +80,207 @@ export const TenantAdminService = { return requestJson(tenantApiPath(tenantCode, `/admin/users/${userID}/join`), { method: 'POST' }); + }, + + async listOrders( + tenantCode, + { + page, + limit, + user_id, + username, + content_id, + content_title, + type, + status, + created_at_from, + created_at_to, + paid_at_from, + paid_at_to, + amount_paid_min, + amount_paid_max, + sortField, + sortOrder + } = {} + ) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + user_id, + username, + content_id, + content_title, + type, + status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to), + paid_at_from: iso(paid_at_from), + paid_at_to: iso(paid_at_to), + amount_paid_min, + amount_paid_max + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson(tenantApiPath(tenantCode, '/admin/orders'), { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async getOrderDetail(tenantCode, orderID) { + if (!orderID) throw new Error('orderID is required'); + return requestJson(tenantApiPath(tenantCode, `/admin/orders/${orderID}`)); + }, + async refundOrder(tenantCode, orderID, { force, reason, idempotency_key } = {}) { + if (!orderID) throw new Error('orderID is required'); + return requestJson(tenantApiPath(tenantCode, `/admin/orders/${orderID}/refund`), { + method: 'POST', + body: { force: Boolean(force), reason, idempotency_key } + }); + }, + + async listInvites(tenantCode, { page, limit, status, code } = {}) { + const data = await requestJson(tenantApiPath(tenantCode, '/admin/invites'), { + query: { page, limit, status, code } + }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async createInvite(tenantCode, { code, max_uses, expires_at, remark } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + return requestJson(tenantApiPath(tenantCode, '/admin/invites'), { + method: 'POST', + body: { + code, + max_uses: max_uses ?? undefined, + expires_at: iso(expires_at), + remark + } + }); + }, + async disableInvite(tenantCode, inviteID, { reason } = {}) { + if (!inviteID) throw new Error('inviteID is required'); + return requestJson(tenantApiPath(tenantCode, `/admin/invites/${inviteID}/disable`), { + method: 'PATCH', + body: { reason } + }); + }, + + async listLedgers( + tenantCode, + { page, limit, operator_user_id, user_id, type, order_id, biz_ref_type, biz_ref_id, created_at_from, created_at_to } = {} + ) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + const data = await requestJson(tenantApiPath(tenantCode, '/admin/ledgers'), { + query: { + page, + limit, + operator_user_id, + user_id, + type, + order_id, + biz_ref_type, + biz_ref_id, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + } + }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + + async listContents( + tenantCode, + { + page, + limit, + id, + user_id, + keyword, + status, + visibility, + published_at_from, + published_at_to, + created_at_from, + created_at_to, + sortField, + sortOrder + } = {} + ) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + user_id, + keyword, + status, + visibility, + published_at_from: iso(published_at_from), + published_at_to: iso(published_at_to), + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson(tenantApiPath(tenantCode, '/admin/contents'), { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async updateContent(tenantCode, contentID, { title, description, visibility, status, preview_seconds } = {}) { + if (!contentID) throw new Error('contentID is required'); + return requestJson(tenantApiPath(tenantCode, `/admin/contents/${contentID}`), { + method: 'PATCH', + body: { + title: title ?? undefined, + description: description ?? undefined, + visibility: visibility ?? undefined, + status: status ?? undefined, + preview_seconds: preview_seconds ?? undefined + } + }); } }; - diff --git a/frontend/tenant_admin/src/views/tenantadmin/Contents.vue b/frontend/tenant_admin/src/views/tenantadmin/Contents.vue new file mode 100644 index 0000000..d8efe11 --- /dev/null +++ b/frontend/tenant_admin/src/views/tenantadmin/Contents.vue @@ -0,0 +1,355 @@ + + + + diff --git a/frontend/tenant_admin/src/views/tenantadmin/EnterTenant.vue b/frontend/tenant_admin/src/views/tenantadmin/EnterTenant.vue index 111aa20..0319ce9 100644 --- a/frontend/tenant_admin/src/views/tenantadmin/EnterTenant.vue +++ b/frontend/tenant_admin/src/views/tenantadmin/EnterTenant.vue @@ -1,37 +1,173 @@ - diff --git a/frontend/tenant_admin/src/views/tenantadmin/Invites.vue b/frontend/tenant_admin/src/views/tenantadmin/Invites.vue new file mode 100644 index 0000000..73bb2e1 --- /dev/null +++ b/frontend/tenant_admin/src/views/tenantadmin/Invites.vue @@ -0,0 +1,300 @@ + + +