diff --git a/backend/app/middlewares/middlewares.go b/backend/app/middlewares/middlewares.go index 6da38f0..78b243e 100644 --- a/backend/app/middlewares/middlewares.go +++ b/backend/app/middlewares/middlewares.go @@ -64,9 +64,6 @@ func (m *Middlewares) authenticate(ctx fiber.Ctx, requireToken bool) error { if user.Status == consts.UserStatusBanned { return errorx.ErrAccountDisabled } - if user.Status == consts.UserStatusBanned { - return errorx.ErrAccountDisabled - } // Set Context ctx.Locals(consts.CtxKeyUser, user) @@ -104,6 +101,9 @@ func (m *Middlewares) SuperAuth(ctx fiber.Ctx) error { if err != nil { return errorx.ErrUnauthorized.WithCause(err).WithMsg("UserNotFound") } + if user.Status == consts.UserStatusBanned { + return errorx.ErrAccountDisabled + } if !hasRole(user.Roles, consts.RoleSuperAdmin) { return errorx.ErrForbidden.WithMsg("无权限访问") diff --git a/backend/app/services/super.go b/backend/app/services/super.go index be018f3..ff1db34 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -27,12 +27,22 @@ type super struct { } func (s *super) Login(ctx context.Context, form *super_dto.LoginForm) (*super_dto.LoginResponse, error) { - tbl, q := models.UserQuery.QueryContext(ctx) - u, err := q.Where(tbl.Username.Eq(form.Username)).First() - if err != nil { - return nil, errorx.ErrInvalidCredentials.WithMsg("账号或密码错误") + if form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("登录参数不能为空") } - if u.Password != form.Password { + username := strings.TrimSpace(form.Username) + password := strings.TrimSpace(form.Password) + if username == "" || password == "" { + return nil, errorx.ErrInvalidParameter.WithMsg("账号或密码不能为空") + } + + // 校验账号与权限。 + tbl, q := models.UserQuery.QueryContext(ctx) + u, err := q.Where(tbl.Username.Eq(username)).First() + if err != nil { + return nil, errorx.ErrInvalidCredentials.WithCause(err).WithMsg("账号或密码错误") + } + if u.Password != password { return nil, errorx.ErrInvalidCredentials.WithMsg("账号或密码错误") } if u.Status == consts.UserStatusBanned { @@ -42,11 +52,12 @@ func (s *super) Login(ctx context.Context, form *super_dto.LoginForm) (*super_dt return nil, errorx.ErrForbidden.WithMsg("无权限访问") } + // 生成登录令牌。 token, err := s.jwt.CreateToken(s.jwt.CreateClaims(jwt_provider.BaseClaims{ UserID: u.ID, })) if err != nil { - return nil, errorx.ErrInternalError.WithMsg("生成令牌失败") + return nil, errorx.ErrInternalError.WithCause(err).WithMsg("生成令牌失败") } return &super_dto.LoginResponse{ @@ -68,7 +79,10 @@ func (s *super) CheckToken(ctx context.Context, token string) (*super_dto.LoginR tbl, q := models.UserQuery.QueryContext(ctx) u, err := q.Where(tbl.ID.Eq(claims.UserID)).First() if err != nil { - return nil, errorx.ErrUnauthorized.WithMsg("UserNotFound") + return nil, errorx.ErrUnauthorized.WithCause(err).WithMsg("UserNotFound") + } + if u.Status == consts.UserStatusBanned { + return nil, errorx.ErrAccountDisabled } if !hasRole(u.Roles, consts.RoleSuperAdmin) { return nil, errorx.ErrForbidden.WithMsg("无权限访问") @@ -78,7 +92,7 @@ func (s *super) CheckToken(ctx context.Context, token string) (*super_dto.LoginR UserID: u.ID, })) if err != nil { - return nil, errorx.ErrInternalError.WithMsg("生成令牌失败") + return nil, errorx.ErrInternalError.WithCause(err).WithMsg("生成令牌失败") } return &super_dto.LoginResponse{ diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 4762761..20bb667 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -16,6 +16,7 @@ import ( . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/suite" "go.ipao.vip/atom/contracts" + "go.ipao.vip/gen/types" "go.uber.org/dig" ) @@ -74,6 +75,60 @@ func (s *SuperTestSuite) Test_ListUsers() { }) } +func (s *SuperTestSuite) Test_LoginAndCheckToken() { + Convey("Login and CheckToken", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser) + + admin := &models.User{ + Username: "super_admin", + Password: "pass123", + Roles: types.Array[consts.Role]{consts.RoleSuperAdmin}, + Status: consts.UserStatusVerified, + } + normal := &models.User{ + Username: "normal_user", + Password: "pass123", + Status: consts.UserStatusVerified, + } + models.UserQuery.WithContext(ctx).Create(admin, normal) + + Convey("should login as super admin", func() { + res, err := Super.Login(ctx, &super_dto.LoginForm{ + Username: admin.Username, + Password: admin.Password, + }) + So(err, ShouldBeNil) + So(res, ShouldNotBeNil) + So(res.Token, ShouldNotBeBlank) + So(res.User.ID, ShouldEqual, admin.ID) + }) + + Convey("should reject non-super admin", func() { + _, err := Super.Login(ctx, &super_dto.LoginForm{ + Username: normal.Username, + Password: normal.Password, + }) + So(err, ShouldNotBeNil) + }) + + Convey("should refresh token", func() { + loginRes, err := Super.Login(ctx, &super_dto.LoginForm{ + Username: admin.Username, + Password: admin.Password, + }) + So(err, ShouldBeNil) + + token := "Bearer " + loginRes.Token + checkRes, err := Super.CheckToken(ctx, token) + So(err, ShouldBeNil) + So(checkRes, ShouldNotBeNil) + So(checkRes.Token, ShouldNotBeBlank) + So(checkRes.User.ID, ShouldEqual, admin.ID) + }) + }) +} + func (s *SuperTestSuite) Test_CreateTenant() { Convey("CreateTenant", s.T(), func() { ctx := s.T().Context() diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 96173b2..aca6f7b 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1708,6 +1708,208 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/creator/coupons": { + "get": { + "description": "List coupon templates", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List coupons", + "parameters": [ + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Type (fix_amount/discount)", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Status (active/expired)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/requests.Pager" + } + } + } + }, + "post": { + "description": "Create coupon template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Create coupon", + "parameters": [ + { + "description": "Coupon form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.CouponItem" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/coupons/{id}": { + "get": { + "description": "Get coupon template detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Get coupon", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Coupon ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.CouponItem" + } + } + } + }, + "put": { + "description": "Update coupon template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Update coupon", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Coupon ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Coupon form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.CouponItem" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/coupons/{id}/grant": { + "post": { + "description": "Grant coupon to users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Grant coupon", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Coupon ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Grant form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponGrantForm" + } + } + ], + "responses": { + "200": { + "description": "Granted", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/creator/dashboard": { "get": { "description": "Get creator dashboard stats", @@ -2278,6 +2480,76 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/me/coupons/available": { + "get": { + "description": "List coupons available for the given order amount", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserCenter" + ], + "summary": "List available coupons", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Order amount (cents)", + "name": "amount", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserCouponItem" + } + } + } + } + } + }, + "/t/{tenantCode}/v1/me/coupons/receive": { + "post": { + "description": "Receive a coupon by coupon_id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserCenter" + ], + "summary": "Receive coupon", + "parameters": [ + { + "description": "Receive form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponReceiveForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserCouponItem" + } + } + } + } + }, "/t/{tenantCode}/v1/me/favorites": { "get": { "description": "Get favorites", @@ -4187,6 +4459,166 @@ const docTemplate = `{ } } }, + "dto.CouponCreateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description 优惠券描述。", + "type": "string" + }, + "end_at": { + "description": "EndAt 过期时间(RFC3339,可为空)。", + "type": "string" + }, + "max_discount": { + "description": "MaxDiscount 折扣券最高抵扣金额(分)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 使用门槛金额(分)。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339,可为空)。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 发行总量(0 表示不限量)。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型(fix_amount/discount)。", + "type": "string" + }, + "value": { + "description": "Value 优惠券面值(分/折扣百分比)。", + "type": "integer" + } + } + }, + "dto.CouponGrantForm": { + "type": "object", + "properties": { + "user_ids": { + "description": "UserIDs 领取用户ID集合。", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "dto.CouponItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "description": { + "description": "Description 优惠券描述。", + "type": "string" + }, + "end_at": { + "description": "EndAt 过期时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 券模板ID。", + "type": "integer" + }, + "max_discount": { + "description": "MaxDiscount 折扣券最高抵扣金额(分)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 使用门槛金额(分)。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339)。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 发行总量。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型(fix_amount/discount)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "used_quantity": { + "description": "UsedQuantity 已使用数量。", + "type": "integer" + }, + "value": { + "description": "Value 优惠券面值(分/折扣百分比)。", + "type": "integer" + } + } + }, + "dto.CouponReceiveForm": { + "type": "object", + "properties": { + "coupon_id": { + "description": "CouponID 券模板ID。", + "type": "integer" + } + } + }, + "dto.CouponUpdateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description 优惠券描述(为空表示不修改)。", + "type": "string" + }, + "end_at": { + "description": "EndAt 过期时间(RFC3339,可为空)。", + "type": "string" + }, + "max_discount": { + "description": "MaxDiscount 折扣券最高抵扣金额(分)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 使用门槛金额(分)。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339,可为空)。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题(为空表示不修改)。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 发行总量(0 表示不限量)。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型(fix_amount/discount)。", + "type": "string" + }, + "value": { + "description": "Value 优惠券面值(分/折扣百分比)。", + "type": "integer" + } + } + }, "dto.DashboardStats": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index b3fed3a..eddaf88 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1702,6 +1702,208 @@ } } }, + "/t/{tenantCode}/v1/creator/coupons": { + "get": { + "description": "List coupon templates", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List coupons", + "parameters": [ + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Type (fix_amount/discount)", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Status (active/expired)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Keyword", + "name": "keyword", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/requests.Pager" + } + } + } + }, + "post": { + "description": "Create coupon template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Create coupon", + "parameters": [ + { + "description": "Coupon form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.CouponItem" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/coupons/{id}": { + "get": { + "description": "Get coupon template detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Get coupon", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Coupon ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.CouponItem" + } + } + } + }, + "put": { + "description": "Update coupon template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Update coupon", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Coupon ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Coupon form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponUpdateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.CouponItem" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/coupons/{id}/grant": { + "post": { + "description": "Grant coupon to users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Grant coupon", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Coupon ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Grant form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponGrantForm" + } + } + ], + "responses": { + "200": { + "description": "Granted", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/creator/dashboard": { "get": { "description": "Get creator dashboard stats", @@ -2272,6 +2474,76 @@ } } }, + "/t/{tenantCode}/v1/me/coupons/available": { + "get": { + "description": "List coupons available for the given order amount", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserCenter" + ], + "summary": "List available coupons", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Order amount (cents)", + "name": "amount", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.UserCouponItem" + } + } + } + } + } + }, + "/t/{tenantCode}/v1/me/coupons/receive": { + "post": { + "description": "Receive a coupon by coupon_id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "UserCenter" + ], + "summary": "Receive coupon", + "parameters": [ + { + "description": "Receive form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.CouponReceiveForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserCouponItem" + } + } + } + } + }, "/t/{tenantCode}/v1/me/favorites": { "get": { "description": "Get favorites", @@ -4181,6 +4453,166 @@ } } }, + "dto.CouponCreateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description 优惠券描述。", + "type": "string" + }, + "end_at": { + "description": "EndAt 过期时间(RFC3339,可为空)。", + "type": "string" + }, + "max_discount": { + "description": "MaxDiscount 折扣券最高抵扣金额(分)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 使用门槛金额(分)。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339,可为空)。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 发行总量(0 表示不限量)。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型(fix_amount/discount)。", + "type": "string" + }, + "value": { + "description": "Value 优惠券面值(分/折扣百分比)。", + "type": "integer" + } + } + }, + "dto.CouponGrantForm": { + "type": "object", + "properties": { + "user_ids": { + "description": "UserIDs 领取用户ID集合。", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "dto.CouponItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "description": { + "description": "Description 优惠券描述。", + "type": "string" + }, + "end_at": { + "description": "EndAt 过期时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 券模板ID。", + "type": "integer" + }, + "max_discount": { + "description": "MaxDiscount 折扣券最高抵扣金额(分)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 使用门槛金额(分)。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339)。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 发行总量。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型(fix_amount/discount)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "used_quantity": { + "description": "UsedQuantity 已使用数量。", + "type": "integer" + }, + "value": { + "description": "Value 优惠券面值(分/折扣百分比)。", + "type": "integer" + } + } + }, + "dto.CouponReceiveForm": { + "type": "object", + "properties": { + "coupon_id": { + "description": "CouponID 券模板ID。", + "type": "integer" + } + } + }, + "dto.CouponUpdateForm": { + "type": "object", + "properties": { + "description": { + "description": "Description 优惠券描述(为空表示不修改)。", + "type": "string" + }, + "end_at": { + "description": "EndAt 过期时间(RFC3339,可为空)。", + "type": "string" + }, + "max_discount": { + "description": "MaxDiscount 折扣券最高抵扣金额(分)。", + "type": "integer" + }, + "min_order_amount": { + "description": "MinOrderAmount 使用门槛金额(分)。", + "type": "integer" + }, + "start_at": { + "description": "StartAt 生效时间(RFC3339,可为空)。", + "type": "string" + }, + "title": { + "description": "Title 优惠券标题(为空表示不修改)。", + "type": "string" + }, + "total_quantity": { + "description": "TotalQuantity 发行总量(0 表示不限量)。", + "type": "integer" + }, + "type": { + "description": "Type 优惠券类型(fix_amount/discount)。", + "type": "string" + }, + "value": { + "description": "Value 优惠券面值(分/折扣百分比)。", + "type": "integer" + } + } + }, "dto.DashboardStats": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 75e54a6..f4c87a6 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -472,6 +472,122 @@ definitions: description: Title 内容标题(为空表示不修改)。 type: string type: object + dto.CouponCreateForm: + properties: + description: + description: Description 优惠券描述。 + type: string + end_at: + description: EndAt 过期时间(RFC3339,可为空)。 + type: string + max_discount: + description: MaxDiscount 折扣券最高抵扣金额(分)。 + type: integer + min_order_amount: + description: MinOrderAmount 使用门槛金额(分)。 + type: integer + start_at: + description: StartAt 生效时间(RFC3339,可为空)。 + type: string + title: + description: Title 优惠券标题。 + type: string + total_quantity: + description: TotalQuantity 发行总量(0 表示不限量)。 + type: integer + type: + description: Type 优惠券类型(fix_amount/discount)。 + type: string + value: + description: Value 优惠券面值(分/折扣百分比)。 + type: integer + type: object + dto.CouponGrantForm: + properties: + user_ids: + description: UserIDs 领取用户ID集合。 + items: + type: integer + type: array + type: object + dto.CouponItem: + properties: + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + description: + description: Description 优惠券描述。 + type: string + end_at: + description: EndAt 过期时间(RFC3339)。 + type: string + id: + description: ID 券模板ID。 + type: integer + max_discount: + description: MaxDiscount 折扣券最高抵扣金额(分)。 + type: integer + min_order_amount: + description: MinOrderAmount 使用门槛金额(分)。 + type: integer + start_at: + description: StartAt 生效时间(RFC3339)。 + type: string + title: + description: Title 优惠券标题。 + type: string + total_quantity: + description: TotalQuantity 发行总量。 + type: integer + type: + description: Type 优惠券类型(fix_amount/discount)。 + type: string + updated_at: + description: UpdatedAt 更新时间(RFC3339)。 + type: string + used_quantity: + description: UsedQuantity 已使用数量。 + type: integer + value: + description: Value 优惠券面值(分/折扣百分比)。 + type: integer + type: object + dto.CouponReceiveForm: + properties: + coupon_id: + description: CouponID 券模板ID。 + type: integer + type: object + dto.CouponUpdateForm: + properties: + description: + description: Description 优惠券描述(为空表示不修改)。 + type: string + end_at: + description: EndAt 过期时间(RFC3339,可为空)。 + type: string + max_discount: + description: MaxDiscount 折扣券最高抵扣金额(分)。 + type: integer + min_order_amount: + description: MinOrderAmount 使用门槛金额(分)。 + type: integer + start_at: + description: StartAt 生效时间(RFC3339,可为空)。 + type: string + title: + description: Title 优惠券标题(为空表示不修改)。 + type: string + total_quantity: + description: TotalQuantity 发行总量(0 表示不限量)。 + type: integer + type: + description: Type 优惠券类型(fix_amount/discount)。 + type: string + value: + description: Value 优惠券面值(分/折扣百分比)。 + type: integer + type: object dto.DashboardStats: properties: new_messages: @@ -2916,6 +3032,140 @@ paths: summary: Update content tags: - CreatorCenter + /t/{tenantCode}/v1/creator/coupons: + get: + consumes: + - application/json + description: List coupon templates + parameters: + - description: Page + in: query + name: page + type: integer + - description: Limit + in: query + name: limit + type: integer + - description: Type (fix_amount/discount) + in: query + name: type + type: string + - description: Status (active/expired) + in: query + name: status + type: string + - description: Keyword + in: query + name: keyword + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/requests.Pager' + summary: List coupons + tags: + - CreatorCenter + post: + consumes: + - application/json + description: Create coupon template + parameters: + - description: Coupon form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.CouponCreateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.CouponItem' + summary: Create coupon + tags: + - CreatorCenter + /t/{tenantCode}/v1/creator/coupons/{id}: + get: + consumes: + - application/json + description: Get coupon template detail + parameters: + - description: Coupon ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.CouponItem' + summary: Get coupon + tags: + - CreatorCenter + put: + consumes: + - application/json + description: Update coupon template + parameters: + - description: Coupon ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Coupon form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.CouponUpdateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.CouponItem' + summary: Update coupon + tags: + - CreatorCenter + /t/{tenantCode}/v1/creator/coupons/{id}/grant: + post: + consumes: + - application/json + description: Grant coupon to users + parameters: + - description: Coupon ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Grant form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.CouponGrantForm' + produces: + - application/json + responses: + "200": + description: Granted + schema: + type: string + summary: Grant coupon + tags: + - CreatorCenter /t/{tenantCode}/v1/creator/dashboard: get: consumes: @@ -3290,6 +3540,52 @@ paths: summary: List coupons tags: - UserCenter + /t/{tenantCode}/v1/me/coupons/available: + get: + consumes: + - application/json + description: List coupons available for the given order amount + parameters: + - description: Order amount (cents) + format: int64 + in: query + name: amount + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/dto.UserCouponItem' + type: array + summary: List available coupons + tags: + - UserCenter + /t/{tenantCode}/v1/me/coupons/receive: + post: + consumes: + - application/json + description: Receive a coupon by coupon_id + parameters: + - description: Receive form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.CouponReceiveForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.UserCouponItem' + summary: Receive coupon + tags: + - UserCenter /t/{tenantCode}/v1/me/favorites: get: consumes: diff --git a/docs/todo_list.md b/docs/todo_list.md index 9a22cc9..4178d0d 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -15,7 +15,7 @@ ## P0(必须先做) -### 1) 租户成员体系(加入/邀请/审核) +### 1) 租户成员体系(加入/邀请/审核)(已完成) **需求目标** - 完成租户成员生命周期:申请加入、审核通过/拒绝、邀请加入。 - tenant_only 内容在“成员审核通过”后可访问;未审核需前置引导。 @@ -39,7 +39,7 @@ - 审核通过后,tenant_only 可访问;未通过不可访问。 - 邀请链接过期/重复使用处理。 -### 2) 鉴权与权限收口(必需) +### 2) 鉴权与权限收口(必需)(已完成) **需求目标** - 受保护接口强制鉴权,超管接口增加 `super_admin` 角色校验。 - 补齐 `Super.Login / CheckToken` 逻辑。 @@ -191,6 +191,8 @@ --- ## 已完成 +- 租户成员体系(加入/邀请/审核)。 +- 鉴权与权限收口(AuthOptional/AuthRequired、super_admin 校验、Super.Login/CheckToken)。 - 内容可见性与 tenant_only 访问控制。 - OTP 登录流程与租户成员校验(未加入拒绝登录)。 - ID 类型已统一为 int64(仅保留 upload_id/external_id/uuid 等非数字标识)。 diff --git a/frontend/portal/src/App.vue b/frontend/portal/src/App.vue index 87ed26e..98240ae 100644 --- a/frontend/portal/src/App.vue +++ b/frontend/portal/src/App.vue @@ -1,3 +1,3 @@ \ No newline at end of file + diff --git a/frontend/portal/src/api/auth.js b/frontend/portal/src/api/auth.js index 77e779f..5dec05b 100644 --- a/frontend/portal/src/api/auth.js +++ b/frontend/portal/src/api/auth.js @@ -1,6 +1,7 @@ -import { request } from '../utils/request'; +import { request } from "../utils/request"; export const authApi = { - sendOTP: (phone) => request('/auth/otp', { method: 'POST', body: { phone } }), - login: (phone, otp) => request('/auth/login', { method: 'POST', body: { phone, otp } }), + sendOTP: (phone) => request("/auth/otp", { method: "POST", body: { phone } }), + login: (phone, otp) => + request("/auth/login", { method: "POST", body: { phone, otp } }), }; diff --git a/frontend/portal/src/api/common.js b/frontend/portal/src/api/common.js index ba42538..541ea94 100644 --- a/frontend/portal/src/api/common.js +++ b/frontend/portal/src/api/common.js @@ -1,131 +1,131 @@ -import { request } from '../utils/request'; -import { getTenantCode } from '../utils/tenant'; +import { request } from "../utils/request"; +import { getTenantCode } from "../utils/tenant"; export const commonApi = { - getOptions: () => request('/common/options'), - checkHash: (hash) => request(`/upload/check?hash=${hash}`), - deleteMedia: (id) => request(`/media-assets/${id}`, { method: 'DELETE' }), - upload: (file, type) => { + getOptions: () => request("/common/options"), + checkHash: (hash) => request(`/upload/check?hash=${hash}`), + deleteMedia: (id) => request(`/media-assets/${id}`, { method: "DELETE" }), + upload: (file, type) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + return request("/upload", { method: "POST", body: formData }); + }, + uploadMultipart: (file, hash, type, onProgress) => { + const controller = new AbortController(); + const signal = controller.signal; + + const promise = (async () => { + // 1. Check Hash + try { + const res = await commonApi.checkHash(hash); + if (res) { + if (onProgress) onProgress(100); + return res; + } + } catch (e) { + // Ignore hash check errors + } + + if (signal.aborted) throw new Error("Aborted"); + + // 2. Init + const initRes = await request("/upload/init", { + method: "POST", + body: { + filename: file.name, + size: file.size, + mime_type: file.type, + hash: hash, + type: type, + }, + signal, + }); + + const { upload_id, chunk_size } = initRes; + const totalChunks = Math.ceil(file.size / chunk_size); + + // 3. Upload Parts + for (let i = 0; i < totalChunks; i++) { + if (signal.aborted) throw new Error("Aborted"); + + const start = i * chunk_size; + const end = Math.min(start + chunk_size, file.size); + const chunk = file.slice(start, end); + const formData = new FormData(); - formData.append('file', file); - formData.append('type', type); - return request('/upload', { method: 'POST', body: formData }); - }, - uploadMultipart: (file, hash, type, onProgress) => { - const controller = new AbortController(); - const signal = controller.signal; + formData.append("file", chunk); + formData.append("upload_id", upload_id); + formData.append("part_number", i + 1); - const promise = (async () => { - // 1. Check Hash - try { - const res = await commonApi.checkHash(hash); - if (res) { - if (onProgress) onProgress(100); - return res; - } - } catch (e) { - // Ignore hash check errors - } - - if (signal.aborted) throw new Error('Aborted'); - - // 2. Init - const initRes = await request('/upload/init', { - method: 'POST', - body: { - filename: file.name, - size: file.size, - mime_type: file.type, - hash: hash, - type: type - }, - signal - }); - - const { upload_id, chunk_size } = initRes; - const totalChunks = Math.ceil(file.size / chunk_size); - - // 3. Upload Parts - for (let i = 0; i < totalChunks; i++) { - if (signal.aborted) throw new Error('Aborted'); - - const start = i * chunk_size; - const end = Math.min(start + chunk_size, file.size); - const chunk = file.slice(start, end); - - const formData = new FormData(); - formData.append('file', chunk); - formData.append('upload_id', upload_id); - formData.append('part_number', i + 1); - - // request helper with FormData handles content-type, but we need signal - await request('/upload/part', { - method: 'POST', - body: formData, - signal - }); - - if (onProgress) { - const percent = Math.round(((i + 1) / totalChunks) * 100); - onProgress(percent); - } - } - - // 4. Complete - return request('/upload/complete', { - method: 'POST', - body: { upload_id }, - signal - }); - })(); - - return { promise, abort: () => controller.abort() }; - }, - uploadWithProgress: (file, type, onProgress) => { - let xhr; - const promise = new Promise((resolve, reject) => { - const formData = new FormData(); - formData.append('file', file); - formData.append('type', type); - - xhr = new XMLHttpRequest(); - const tenantCode = getTenantCode(); - if (!tenantCode) { - reject(new Error('Tenant code missing in URL')); - return; - } - xhr.open('POST', `/t/${tenantCode}/v1/upload`); - - const token = localStorage.getItem('token'); - if (token) { - xhr.setRequestHeader('Authorization', `Bearer ${token}`); - } - - xhr.upload.onprogress = (event) => { - if (event.lengthComputable && onProgress) { - const percentComplete = (event.loaded / event.total) * 100; - onProgress(percentComplete); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - const response = JSON.parse(xhr.responseText); - resolve(response); - } catch (e) { - reject(e); - } - } else { - reject(new Error(xhr.statusText || 'Upload failed')); - } - }; - - xhr.onerror = () => reject(new Error('Network Error')); - xhr.onabort = () => reject(new Error('Aborted')); - xhr.send(formData); + // request helper with FormData handles content-type, but we need signal + await request("/upload/part", { + method: "POST", + body: formData, + signal, }); - - return { promise, abort: () => xhr.abort() }; - } + + if (onProgress) { + const percent = Math.round(((i + 1) / totalChunks) * 100); + onProgress(percent); + } + } + + // 4. Complete + return request("/upload/complete", { + method: "POST", + body: { upload_id }, + signal, + }); + })(); + + return { promise, abort: () => controller.abort() }; + }, + uploadWithProgress: (file, type, onProgress) => { + let xhr; + const promise = new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + + xhr = new XMLHttpRequest(); + const tenantCode = getTenantCode(); + if (!tenantCode) { + reject(new Error("Tenant code missing in URL")); + return; + } + xhr.open("POST", `/t/${tenantCode}/v1/upload`); + + const token = localStorage.getItem("token"); + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + } + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && onProgress) { + const percentComplete = (event.loaded / event.total) * 100; + onProgress(percentComplete); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const response = JSON.parse(xhr.responseText); + resolve(response); + } catch (e) { + reject(e); + } + } else { + reject(new Error(xhr.statusText || "Upload failed")); + } + }; + + xhr.onerror = () => reject(new Error("Network Error")); + xhr.onabort = () => reject(new Error("Aborted")); + xhr.send(formData); + }); + + return { promise, abort: () => xhr.abort() }; + }, }; diff --git a/frontend/portal/src/api/content.js b/frontend/portal/src/api/content.js index 9440dcf..af20497 100644 --- a/frontend/portal/src/api/content.js +++ b/frontend/portal/src/api/content.js @@ -1,22 +1,25 @@ -import { request } from '../utils/request'; +import { request } from "../utils/request"; export const contentApi = { - list: (params) => { - if (params.tenant_id) { - const { tenant_id: tenantID, ...rest } = params; - const qs = new URLSearchParams(rest).toString(); - return request(`/creators/${tenantID}/contents?${qs}`); - } - const qs = new URLSearchParams(params).toString(); - return request(`/contents?${qs}`); - }, - get: (id) => request(`/contents/${id}`), - listComments: (id, page) => request(`/contents/${id}/comments?page=${page || 1}`), - createComment: (id, data) => request(`/contents/${id}/comments`, { method: 'POST', body: data }), - likeComment: (id) => request(`/comments/${id}/like`, { method: 'POST' }), - addLike: (id) => request(`/contents/${id}/like`, { method: 'POST' }), - removeLike: (id) => request(`/contents/${id}/like`, { method: 'DELETE' }), - addFavorite: (id) => request(`/contents/${id}/favorite`, { method: 'POST' }), - removeFavorite: (id) => request(`/contents/${id}/favorite`, { method: 'DELETE' }), - listTopics: () => request('/topics'), + list: (params) => { + if (params.tenant_id) { + const { tenant_id: tenantID, ...rest } = params; + const qs = new URLSearchParams(rest).toString(); + return request(`/creators/${tenantID}/contents?${qs}`); + } + const qs = new URLSearchParams(params).toString(); + return request(`/contents?${qs}`); + }, + get: (id) => request(`/contents/${id}`), + listComments: (id, page) => + request(`/contents/${id}/comments?page=${page || 1}`), + createComment: (id, data) => + request(`/contents/${id}/comments`, { method: "POST", body: data }), + likeComment: (id) => request(`/comments/${id}/like`, { method: "POST" }), + addLike: (id) => request(`/contents/${id}/like`, { method: "POST" }), + removeLike: (id) => request(`/contents/${id}/like`, { method: "DELETE" }), + addFavorite: (id) => request(`/contents/${id}/favorite`, { method: "POST" }), + removeFavorite: (id) => + request(`/contents/${id}/favorite`, { method: "DELETE" }), + listTopics: () => request("/topics"), }; diff --git a/frontend/portal/src/api/order.js b/frontend/portal/src/api/order.js index 7ae8b94..190b41f 100644 --- a/frontend/portal/src/api/order.js +++ b/frontend/portal/src/api/order.js @@ -1,7 +1,8 @@ -import { request } from '../utils/request'; +import { request } from "../utils/request"; export const orderApi = { - create: (data) => request('/orders', { method: 'POST', body: data }), - pay: (id, data) => request(`/orders/${id}/pay`, { method: 'POST', body: data }), - status: (id) => request(`/orders/${id}/status`), + create: (data) => request("/orders", { method: "POST", body: data }), + pay: (id, data) => + request(`/orders/${id}/pay`, { method: "POST", body: data }), + status: (id) => request(`/orders/${id}/status`), }; diff --git a/frontend/portal/src/api/tenant.js b/frontend/portal/src/api/tenant.js index c4f72bd..fb9314c 100644 --- a/frontend/portal/src/api/tenant.js +++ b/frontend/portal/src/api/tenant.js @@ -1,11 +1,11 @@ -import { request } from '../utils/request'; +import { request } from "../utils/request"; export const tenantApi = { - get: (id) => request(`/tenants/${id}`), - list: (params) => { - const qs = new URLSearchParams(params).toString(); - return request(`/tenants?${qs}`); - }, - follow: (id) => request(`/tenants/${id}/follow`, { method: 'POST' }), - unfollow: (id) => request(`/tenants/${id}/follow`, { method: 'DELETE' }), + get: (id) => request(`/tenants/${id}`), + list: (params) => { + const qs = new URLSearchParams(params).toString(); + return request(`/tenants?${qs}`); + }, + follow: (id) => request(`/tenants/${id}/follow`, { method: "POST" }), + unfollow: (id) => request(`/tenants/${id}/follow`, { method: "DELETE" }), }; diff --git a/frontend/portal/src/api/user.js b/frontend/portal/src/api/user.js index bfc400b..25d895b 100644 --- a/frontend/portal/src/api/user.js +++ b/frontend/portal/src/api/user.js @@ -1,25 +1,38 @@ -import { request } from '../utils/request'; +import { request } from "../utils/request"; export const userApi = { - getMe: () => request('/me'), - updateMe: (data) => request('/me', { method: 'PUT', body: data }), - realName: (data) => request('/me/realname', { method: 'POST', body: data }), - getWallet: () => request('/me/wallet'), - recharge: (data) => request('/me/wallet/recharge', { method: 'POST', body: data }), - getOrders: (status) => request(`/me/orders?status=${status || 'all'}`), - getOrder: (id) => request(`/me/orders/${id}`), - getLibrary: () => request('/me/library'), - getFavorites: () => request('/me/favorites'), - addFavorite: (contentId) => request(`/me/favorites?contentId=${contentId}`, { method: 'POST' }), - removeFavorite: (contentId) => request(`/me/favorites/${contentId}`, { method: 'DELETE' }), - getLikes: () => request('/me/likes'), - addLike: (contentId) => request(`/me/likes?contentId=${contentId}`, { method: 'POST' }), - removeLike: (contentId) => request(`/me/likes/${contentId}`, { method: 'DELETE' }), - getNotifications: (type, page) => request(`/me/notifications?type=${type || 'all'}&page=${page || 1}`), - markNotificationRead: (id) => request(`/me/notifications/${id}/read`, { method: 'POST' }), - markAllNotificationsRead: () => request('/me/notifications/read-all', { method: 'POST' }), - getFollowing: () => request('/me/following'), - getCoupons: (status) => request(`/me/coupons?status=${status || 'unused'}`), - getAvailableCoupons: (amount) => request(`/me/coupons/available?amount=${amount}`), - receiveCoupon: (couponId) => request('/me/coupons/receive', { method: 'POST', body: { coupon_id: couponId } }), + getMe: () => request("/me"), + updateMe: (data) => request("/me", { method: "PUT", body: data }), + realName: (data) => request("/me/realname", { method: "POST", body: data }), + getWallet: () => request("/me/wallet"), + recharge: (data) => + request("/me/wallet/recharge", { method: "POST", body: data }), + getOrders: (status) => request(`/me/orders?status=${status || "all"}`), + getOrder: (id) => request(`/me/orders/${id}`), + getLibrary: () => request("/me/library"), + getFavorites: () => request("/me/favorites"), + addFavorite: (contentId) => + request(`/me/favorites?contentId=${contentId}`, { method: "POST" }), + removeFavorite: (contentId) => + request(`/me/favorites/${contentId}`, { method: "DELETE" }), + getLikes: () => request("/me/likes"), + addLike: (contentId) => + request(`/me/likes?contentId=${contentId}`, { method: "POST" }), + removeLike: (contentId) => + request(`/me/likes/${contentId}`, { method: "DELETE" }), + getNotifications: (type, page) => + request(`/me/notifications?type=${type || "all"}&page=${page || 1}`), + markNotificationRead: (id) => + request(`/me/notifications/${id}/read`, { method: "POST" }), + markAllNotificationsRead: () => + request("/me/notifications/read-all", { method: "POST" }), + getFollowing: () => request("/me/following"), + getCoupons: (status) => request(`/me/coupons?status=${status || "unused"}`), + getAvailableCoupons: (amount) => + request(`/me/coupons/available?amount=${amount}`), + receiveCoupon: (couponId) => + request("/me/coupons/receive", { + method: "POST", + body: { coupon_id: couponId }, + }), }; diff --git a/frontend/portal/src/components/AppFooter.vue b/frontend/portal/src/components/AppFooter.vue index 5c4c866..5e640ad 100644 --- a/frontend/portal/src/components/AppFooter.vue +++ b/frontend/portal/src/components/AppFooter.vue @@ -5,7 +5,9 @@
-
+
Q
Quyun @@ -14,15 +16,21 @@ 专业的租户管理与内容交付平台,连接创作者与用户,探索内容的无限可能。

- - - + + +
@@ -30,37 +38,89 @@

关于我们

帮助中心

法律条款

-
+

© 2025 Quyun Platform. All rights reserved.

-

ICP 备 88888888 号-1 | 公安网备 11010102000000 号

+

+ ICP 备 88888888 号-1 | 公安网备 11010102000000 号 +

diff --git a/frontend/portal/src/components/HelloWorld.vue b/frontend/portal/src/components/HelloWorld.vue index 546ebbc..70cd96c 100644 --- a/frontend/portal/src/components/HelloWorld.vue +++ b/frontend/portal/src/components/HelloWorld.vue @@ -1,11 +1,11 @@