From 4419a371073a10bcd669336f32c9bf1d2c4337a5 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Fri, 27 Sep 2024 11:03:21 +0800 Subject: [PATCH] feat(spx-backend): implement Community API Fixes #920 Fixes #948 Signed-off-by: Aofei Sheng --- docs/openapi.yaml | 239 ++--- spx-backend/.env.dev | 2 +- .../cmd/spx-backend/delete_asset_#id.yap | 2 +- .../delete_project_#owner_#name.yap | 2 +- .../delete_project_#owner_#name_liking.yap | 16 + .../delete_user_#username_following.yap | 16 + .../cmd/spx-backend/get_assets_list.yap | 35 +- ...oject-release_#owner_#project_#release.yap | 13 + .../spx-backend/get_project-releases_list.yap | 34 + .../get_project_#owner_#name_liking.yap | 21 + .../cmd/spx-backend/get_projects_list.yap | 72 +- .../cmd/spx-backend/get_user_#username.yap | 13 + .../get_user_#username_following.yap | 21 + .../cmd/spx-backend/get_users_list.yap | 38 + spx-backend/cmd/spx-backend/gop_autogen.go | 812 ++++++++++++---- spx-backend/cmd/spx-backend/middleware.go | 2 +- spx-backend/cmd/spx-backend/post_asset.yap | 12 +- .../cmd/spx-backend/post_asset_#id_click.yap | 12 - .../cmd/spx-backend/post_project-release.yap | 30 + spx-backend/cmd/spx-backend/post_project.yap | 12 +- .../post_project_#owner_#name_liking.yap | 16 + .../post_user_#username_following.yap | 16 + .../cmd/spx-backend/post_util_fileurls.yap | 2 +- .../cmd/spx-backend/post_util_fmtcode.yap | 2 +- spx-backend/cmd/spx-backend/put_user.yap | 30 + spx-backend/cmd/spx-backend/util.go | 10 +- spx-backend/go.mod | 5 +- spx-backend/go.sum | 13 +- spx-backend/init.sql | 42 - spx-backend/internal/controller/aigc_test.go | 106 --- spx-backend/internal/controller/asset.go | 417 ++++---- spx-backend/internal/controller/asset_test.go | 899 ------------------ spx-backend/internal/controller/controller.go | 79 +- .../internal/controller/controller_test.go | 83 -- spx-backend/internal/controller/project.go | 773 +++++++++++++-- .../internal/controller/project_release.go | 248 +++++ .../internal/controller/project_test.go | 608 ------------ spx-backend/internal/controller/user.go | 347 ++++++- spx-backend/internal/controller/user_test.go | 94 -- spx-backend/internal/controller/util_test.go | 155 --- spx-backend/internal/model/asset.go | 133 +-- spx-backend/internal/model/asset_test.go | 218 ----- spx-backend/internal/model/clause.go | 65 -- spx-backend/internal/model/clause_test.go | 78 -- spx-backend/internal/model/file.go | 34 - spx-backend/internal/model/model.go | 107 ++- .../model/{file_test.go => model_test.go} | 0 spx-backend/internal/model/project.go | 110 +-- spx-backend/internal/model/project_release.go | 31 + spx-backend/internal/model/project_test.go | 225 ----- spx-backend/internal/model/query.go | 220 ----- spx-backend/internal/model/query_test.go | 399 -------- spx-backend/internal/model/scan.go | 73 -- spx-backend/internal/model/scan_test.go | 174 ---- spx-backend/internal/model/user.go | 66 ++ .../model/user_project_relationship.go | 69 ++ .../internal/model/user_relationship.go | 62 ++ spx-gui/src/apis/asset.ts | 51 +- spx-gui/src/apis/common/index.ts | 6 +- spx-gui/src/apis/project.ts | 58 +- spx-gui/src/apis/user.ts | 14 +- .../asset/library/AssetAddModal.vue | 25 +- .../asset/library/AssetLibraryModal.vue | 10 +- .../components/editor/navbar/EditorNavbar.vue | 4 +- .../components/project/ProjectCreateModal.vue | 4 +- spx-gui/src/components/project/index.ts | 12 +- .../components/project/item/ProjectItem.vue | 8 +- spx-gui/src/models/common/asset.ts | 8 +- spx-gui/src/models/common/cloud.ts | 8 +- spx-gui/src/models/project/index.ts | 24 +- spx-gui/src/pages/community/home.vue | 2 +- spx-gui/src/pages/community/search.vue | 6 +- 72 files changed, 3211 insertions(+), 4442 deletions(-) create mode 100644 spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap create mode 100644 spx-backend/cmd/spx-backend/delete_user_#username_following.yap create mode 100644 spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap create mode 100644 spx-backend/cmd/spx-backend/get_project-releases_list.yap create mode 100644 spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap create mode 100644 spx-backend/cmd/spx-backend/get_user_#username.yap create mode 100644 spx-backend/cmd/spx-backend/get_user_#username_following.yap create mode 100644 spx-backend/cmd/spx-backend/get_users_list.yap delete mode 100644 spx-backend/cmd/spx-backend/post_asset_#id_click.yap create mode 100644 spx-backend/cmd/spx-backend/post_project-release.yap create mode 100644 spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap create mode 100644 spx-backend/cmd/spx-backend/post_user_#username_following.yap create mode 100644 spx-backend/cmd/spx-backend/put_user.yap delete mode 100644 spx-backend/init.sql delete mode 100644 spx-backend/internal/controller/aigc_test.go delete mode 100644 spx-backend/internal/controller/asset_test.go delete mode 100644 spx-backend/internal/controller/controller_test.go create mode 100644 spx-backend/internal/controller/project_release.go delete mode 100644 spx-backend/internal/controller/project_test.go delete mode 100644 spx-backend/internal/controller/user_test.go delete mode 100644 spx-backend/internal/controller/util_test.go delete mode 100644 spx-backend/internal/model/asset_test.go delete mode 100644 spx-backend/internal/model/clause.go delete mode 100644 spx-backend/internal/model/clause_test.go delete mode 100644 spx-backend/internal/model/file.go rename spx-backend/internal/model/{file_test.go => model_test.go} (100%) create mode 100644 spx-backend/internal/model/project_release.go delete mode 100644 spx-backend/internal/model/project_test.go delete mode 100644 spx-backend/internal/model/query.go delete mode 100644 spx-backend/internal/model/query_test.go delete mode 100644 spx-backend/internal/model/scan.go delete mode 100644 spx-backend/internal/model/scan_test.go create mode 100644 spx-backend/internal/model/user.go create mode 100644 spx-backend/internal/model/user_project_relationship.go create mode 100644 spx-backend/internal/model/user_relationship.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c0315872b..0acaf3c13 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -31,12 +31,12 @@ paths: schema: type: string enum: - - cTime - - uTime + - createdAt + - updatedAt - followedAt - default: cTime + default: createdAt examples: - - cTime + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -103,7 +103,7 @@ paths: schema: $ref: "#/components/schemas/User" - /user/following/{username}: + /user/{username}/following: post: tags: - Users @@ -169,7 +169,7 @@ paths: type: object required: - name - - isPublic + - visibility properties: remixSource: oneOf: @@ -187,8 +187,8 @@ paths: File paths and their corresponding universal URLs associated with the project. **Required if `remixSource` is not provided.** - isPublic: - $ref: "#/components/schemas/Project/properties/isPublic" + visibility: + $ref: "#/components/schemas/Project/properties/visibility" description: $ref: "#/components/schemas/Project/properties/description" instructions: @@ -214,7 +214,10 @@ paths: in: query schema: $ref: "#/components/schemas/Project/properties/owner" - description: Filter projects by the owner's username. + description: | + Filter projects by the owner's username. + + Defaults to the authenticated user if not specified. Use `*` to include projects from all users. - name: remixedFrom in: query schema: @@ -227,15 +230,11 @@ paths: schema: $ref: "#/components/schemas/Project/properties/name" description: Filter projects by name pattern. - - name: isPublic + - name: visibility in: query schema: - $ref: "#/components/schemas/Project/properties/isPublic" - description: | - Filter projects by visibility status. - - - `0`: Personal - - `1`: Public + $ref: "#/components/schemas/Project/properties/visibility" + description: Filter projects by visibility. - name: liker in: query schema: @@ -270,15 +269,15 @@ paths: schema: type: string enum: - - cTime - - uTime + - createdAt + - updatedAt - likeCount - remixCount - recentLikeCount - recentRemixCount - default: cTime + default: createdAt examples: - - cTime + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -349,16 +348,13 @@ paths: schema: type: object required: - - name - files - - isPublic + - visibility properties: - name: - $ref: "#/components/schemas/Project/properties/name" files: $ref: "#/components/schemas/Project/properties/files" - isPublic: - $ref: "#/components/schemas/Project/properties/isPublic" + visibility: + $ref: "#/components/schemas/Project/properties/visibility" description: $ref: "#/components/schemas/Project/properties/description" instructions: @@ -394,7 +390,7 @@ paths: "204": description: Successfully deleted the project. - /project/liking/{owner}/{name}: + /project/{owner}/{name}/liking: post: tags: - Projects @@ -509,12 +505,12 @@ paths: schema: type: string enum: - - cTime - - uTime + - createdAt + - updatedAt - remixCount - default: cTime + default: createdAt examples: - - cTime + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -582,26 +578,24 @@ paths: type: object required: - displayName + - type - category - - assetType - files - filesHash - - isPublic + - visibility properties: displayName: $ref: "#/components/schemas/Asset/properties/displayName" + type: + $ref: "#/components/schemas/Asset/properties/type" category: $ref: "#/components/schemas/Asset/properties/category" - assetType: - $ref: "#/components/schemas/Asset/properties/assetType" files: $ref: "#/components/schemas/Asset/properties/files" filesHash: $ref: "#/components/schemas/Asset/properties/filesHash" - preview: - $ref: "#/components/schemas/Asset/properties/preview" - isPublic: - $ref: "#/components/schemas/Asset/properties/isPublic" + visibility: + $ref: "#/components/schemas/Asset/properties/visibility" responses: "201": description: Successfully created the asset. @@ -626,46 +620,39 @@ paths: in: query schema: $ref: "#/components/schemas/User/properties/username" - description: Filter assets by owner's username. + description: | + Filter assets by owner's username. + + Defaults to the authenticated user if not specified. Use `*` to include assets from all users. + - name: type + in: query + schema: + $ref: "#/components/schemas/Asset/properties/type" + description: Filter assets by type. - name: category in: query schema: $ref: "#/components/schemas/Asset/properties/category" description: Filter assets by category. - - name: assetType - in: query - schema: - $ref: "#/components/schemas/Asset/properties/assetType" - description: | - Filter assets by type. - - - `0`: Sprite - - `1`: Backdrop - - `2`: Sound - name: filesHash in: query schema: $ref: "#/components/schemas/Asset/properties/filesHash" description: Filter assets by files hash. - - name: isPublic + - name: visibility in: query schema: - $ref: "#/components/schemas/Asset/properties/isPublic" - description: | - Filter assets by visibility status. - - - `0`: Personal - - `1`: Public + $ref: "#/components/schemas/Asset/properties/visibility" + description: Filter assets by visibility. - name: orderBy in: query schema: type: string enum: - - default - - time - - clickCount + - createdAt + - updatedAt examples: - - default + - createdAt description: Field by which to order the results. - $ref: "#/components/parameters/SortOrder" - $ref: "#/components/parameters/PageIndex" @@ -726,26 +713,24 @@ paths: type: object required: - displayName + - type - category - - assetType - files - filesHash - - isPublic + - visibility properties: displayName: $ref: "#/components/schemas/Asset/properties/displayName" + type: + $ref: "#/components/schemas/Asset/properties/type" category: $ref: "#/components/schemas/Asset/properties/category" - assetType: - $ref: "#/components/schemas/Asset/properties/assetType" files: $ref: "#/components/schemas/Asset/properties/files" filesHash: $ref: "#/components/schemas/Asset/properties/filesHash" - preview: - $ref: "#/components/schemas/Asset/properties/preview" - isPublic: - $ref: "#/components/schemas/Asset/properties/isPublic" + visibility: + $ref: "#/components/schemas/Asset/properties/visibility" responses: "200": description: Successfully updated the asset. @@ -769,23 +754,6 @@ paths: "204": description: Successfully deleted the asset. - /asset/{id}/click: - post: - tags: - - Assets - summary: Increment the click count of an asset - description: Increase the click count of the specified asset. - parameters: - - name: id - in: path - required: true - schema: - $ref: "#/components/schemas/Model/properties/id" - description: ID of the asset for which to increase click count. - responses: - "204": - description: Successfully increased the click count. - /aigc/matting: post: tags: @@ -928,42 +896,37 @@ components: examples: - 1 description: Unique identifier. - cTime: + createdAt: type: string format: date-time examples: - 2006-01-02T15:04:05Z07:00 description: Creation timestamp. - uTime: + updatedAt: type: string format: date-time examples: - 2006-01-02T15:04:05Z07:00 description: Last update timestamp. - IsPublic: - type: integer - format: int8 + Visibility: + type: string enum: - - 0 - - 1 + - private + - public examples: - - 1 - description: | - Visibility status of the object. - - - `0`: Personal - - `1`: Public + - private + description: Visibility of the object. User: type: object properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" username: type: string examples: @@ -1015,14 +978,15 @@ components: properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" owner: $ref: "#/components/schemas/User/properties/username" remixedFrom: $ref: "#/components/schemas/ProjectReleaseFullName" + nullable: true description: Full name of the project release from which the project is remixed. name: type: string @@ -1045,8 +1009,8 @@ components: assets/sprites/Sprite/index.json: kodo://goplus-builder-usercontent-test/files/FoDx7DeLnvbXK45k23OU7IvzmlKE-215 main.spx: kodo://goplus-builder-usercontent-test/files/Fto5o-5ea0sNMlW_75VgGJCv2AcJ-0 description: File paths and their corresponding universal URLs associated with the project. - isPublic: - $ref: "#/components/schemas/IsPublic" + visibility: + $ref: "#/components/schemas/Visibility" description: type: string examples: @@ -1103,10 +1067,10 @@ components: properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" projectFullName: $ref: "#/components/schemas/ProjectFullName" description: Full name of the project that the release is associated with, in the format `owner/project`. @@ -1142,10 +1106,10 @@ components: properties: id: $ref: "#/components/schemas/Model/properties/id" - cTime: - $ref: "#/components/schemas/Model/properties/cTime" - uTime: - $ref: "#/components/schemas/Model/properties/uTime" + createdAt: + $ref: "#/components/schemas/Model/properties/createdAt" + updatedAt: + $ref: "#/components/schemas/Model/properties/updatedAt" owner: $ref: "#/components/schemas/User/properties/username" description: Username of the asset's owner. @@ -1154,26 +1118,20 @@ components: examples: - NiuXiaoQi description: Display name of the asset. + type: + type: string + enum: + - sprite + - backdrop + - sound + examples: + - sprite + description: Type of the asset. category: type: string examples: - People description: Category to which the asset belongs. - assetType: - type: integer - format: int8 - enum: - - 0 - - 1 - - 2 - examples: - - 0 - description: | - Type of the asset. - - - `0`: Sprite - - `1`: Backdrop - - `2`: Sound files: type: object examples: @@ -1186,21 +1144,8 @@ components: examples: - h1:qUqP5cyxm6YcTAhz05Hph5gvu9M= description: Hash of the asset files. - preview: - type: string - format: uri - examples: - - https://builder.goplus.org/asset_preview.png - description: URL of the asset's preview image. - isPublic: - $ref: "#/components/schemas/IsPublic" - clickCount: - type: integer - format: int64 - minimum: 0 - examples: - - 1 - description: Number of times the asset has been clicked. + visibility: + $ref: "#/components/schemas/Visibility" UpInfo: type: object diff --git a/spx-backend/.env.dev b/spx-backend/.env.dev index 9e6f2c2d5..a53aadc94 100644 --- a/spx-backend/.env.dev +++ b/spx-backend/.env.dev @@ -45,4 +45,4 @@ ZNIZlCk3CrqUzVnAln07K9++5egCisZ0XADLp1cgCZk0NRM7a2LuMDx/iZXw1QaQ Iw== -----END CERTIFICATE-----" GOP_CASDOOR_ORGANIZATIONNAME="GoPlus" -GOP_CASDOOR_APPLICATONNAME="application_x8aevk" +GOP_CASDOOR_APPLICATIONNAME="application_x8aevk" diff --git a/spx-backend/cmd/spx-backend/delete_asset_#id.yap b/spx-backend/cmd/spx-backend/delete_asset_#id.yap index 055e17275..774aeb0f5 100644 --- a/spx-backend/cmd/spx-backend/delete_asset_#id.yap +++ b/spx-backend/cmd/spx-backend/delete_asset_#id.yap @@ -9,4 +9,4 @@ if err := ctrl.DeleteAsset(ctx.Context(), ${id}); err != nil { replyWithInnerError(ctx, err) return } -json nil +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap b/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap index e5c41569c..9aea956c7 100644 --- a/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap +++ b/spx-backend/cmd/spx-backend/delete_project_#owner_#name.yap @@ -9,4 +9,4 @@ if err := ctrl.DeleteProject(ctx.Context(), ${owner}, ${name}); err != nil { replyWithInnerError(ctx, err) return } -json nil +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap b/spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap new file mode 100644 index 000000000..7e15ba1bf --- /dev/null +++ b/spx-backend/cmd/spx-backend/delete_project_#owner_#name_liking.yap @@ -0,0 +1,16 @@ +// Unlike a project. +// +// Request: +// DELETE /project/:owner/:name/liking + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.UnlikeProject(ctx.Context(), ${owner}, ${name}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/delete_user_#username_following.yap b/spx-backend/cmd/spx-backend/delete_user_#username_following.yap new file mode 100644 index 000000000..842c06e93 --- /dev/null +++ b/spx-backend/cmd/spx-backend/delete_user_#username_following.yap @@ -0,0 +1,16 @@ +// Unfollow a user. +// +// Request: +// DELETE /user/:username/following + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.UnfollowUser(ctx.Context(), ${username}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/get_assets_list.yap b/spx-backend/cmd/spx-backend/get_assets_list.yap index 4b67d4cd7..e305b104d 100644 --- a/spx-backend/cmd/spx-backend/get_assets_list.yap +++ b/spx-backend/cmd/spx-backend/get_assets_list.yap @@ -4,18 +4,17 @@ // GET /assets/list import ( - "strconv" - "github.com/goplus/builder/spx-backend/internal/controller" - "github.com/goplus/builder/spx-backend/internal/model" ) ctx := &Context user, _ := controller.UserFromContext(ctx.Context()) -params := &controller.ListAssetsParams{} +params := controller.NewListAssetsParams() -params.Keyword = ${keyword} +if keyword := ${keyword}; keyword != "" { + params.Keyword = &keyword +} switch owner := ${owner}; owner { case "": @@ -23,39 +22,27 @@ case "": replyWithCode(ctx, errorUnauthorized) return } - params.Owner = &user.Name + params.Owner = &user.Username case "*": params.Owner = nil default: params.Owner = &owner } -if category := ${category}; category != "" { - params.Category = &category +if typeParam := ctx.Param("type"); typeParam != "" { + params.Type = &typeParam } -if assetTypeParam := ${assetType}; assetTypeParam != "" { - assetTypeInt, err := strconv.Atoi(assetTypeParam) - if err != nil { - replyWithCode(ctx, errorInvalidArgs) - return - } - assetType := model.AssetType(assetTypeInt) - params.AssetType = &assetType +if category := ${category}; category != "" { + params.Category = &category } if filesHash := ${filesHash}; filesHash != "" { params.FilesHash = &filesHash } -if isPublicParam := ${isPublic}; isPublicParam != "" { - isPublicInt, err := strconv.Atoi(isPublicParam) - if err != nil { - replyWithCode(ctx, errorInvalidArgs) - return - } - isPublic := model.IsPublic(isPublicInt) - params.IsPublic = &isPublic +if visibility := ${visibility}; visibility != "" { + params.Visibility = &visibility } if orderBy := ${orderBy}; orderBy != "" { diff --git a/spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap b/spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap new file mode 100644 index 000000000..19e773810 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_project-release_#owner_#project_#release.yap @@ -0,0 +1,13 @@ +// Get project release by its full name. +// +// Request: +// GET /project-release/:owner/:project/:release + +ctx := &Context + +projectRelease, err := ctrl.GetProjectRelease(ctx.Context(), ${owner}, ${project}, ${release}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json projectRelease diff --git a/spx-backend/cmd/spx-backend/get_project-releases_list.yap b/spx-backend/cmd/spx-backend/get_project-releases_list.yap new file mode 100644 index 000000000..e5c19d870 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_project-releases_list.yap @@ -0,0 +1,34 @@ +// List project releases. +// +// Request: +// GET /project-releases/list + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +params := controller.NewListProjectReleasesParams() + +if projectFullName := ${projectFullName}; projectFullName != "" { + params.ProjectFullName = &projectFullName +} + +if orderBy := ${orderBy}; orderBy != "" { + params.OrderBy = controller.ListProjectReleasesOrderBy(orderBy) +} + +params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) +params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +projectReleases, err := ctrl.ListProjectReleases(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json projectReleases diff --git a/spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap b/spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap new file mode 100644 index 000000000..ca3b24041 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_project_#owner_#name_liking.yap @@ -0,0 +1,21 @@ +// Check if a project is liked by the authenticated user. +// +// Request: +// GET /project/:owner/:name/liking + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +hasLiked, err := ctrl.HasLikedProject(ctx.Context(), ${owner}, ${name}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +if hasLiked { + ctx.text 204, "", "" +} else { + replyWithCode(ctx, errorNotFound) +} diff --git a/spx-backend/cmd/spx-backend/get_projects_list.yap b/spx-backend/cmd/spx-backend/get_projects_list.yap index 16ee50813..19859ee2d 100644 --- a/spx-backend/cmd/spx-backend/get_projects_list.yap +++ b/spx-backend/cmd/spx-backend/get_projects_list.yap @@ -5,25 +5,15 @@ import ( "strconv" + "time" "github.com/goplus/builder/spx-backend/internal/controller" - "github.com/goplus/builder/spx-backend/internal/model" ) ctx := &Context user, _ := controller.UserFromContext(ctx.Context()) -params := &controller.ListProjectsParams{} - -if isPublicParam := ${isPublic}; isPublicParam != "" { - isPublicInt, err := strconv.Atoi(isPublicParam) - if err != nil { - replyWithCode(ctx, errorInvalidArgs) - return - } - isPublic := model.IsPublic(isPublicInt) - params.IsPublic = &isPublic -} +params := controller.NewListProjectsParams() switch owner := ${owner}; owner { case "": @@ -31,13 +21,69 @@ case "": replyWithCode(ctx, errorUnauthorized) return } - params.Owner = &user.Name + params.Owner = &user.Username case "*": params.Owner = nil default: params.Owner = &owner } +if remixedFrom := ${remixedFrom}; remixedFrom != "" { + params.RemixedFrom = &remixedFrom +} + +if keyword := ${keyword}; keyword != "" { + params.Keyword = &keyword +} + +if visibility := ${visibility}; visibility != "" { + params.Visibility = &visibility +} + +if liker := ${liker}; liker != "" { + params.Liker = &liker +} + +if createdAfter := ${createdAfter}; createdAfter != "" { + createdAfterTime, err := time.Parse(time.RFC3339Nano, createdAfter) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid createdAfter") + return + } + params.CreatedAfter = &createdAfterTime +} + +if likesReceivedAfter := ${likesReceivedAfter}; likesReceivedAfter != "" { + likesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, likesReceivedAfter) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid likesReceivedAfter") + return + } + params.LikesReceivedAfter = &likesReceivedAfterTime +} + +if remixesReceivedAfter := ${remixesReceivedAfter}; remixesReceivedAfter != "" { + remixesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, remixesReceivedAfter) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid remixesReceivedAfter") + return + } + params.RemixesReceivedAfter = &remixesReceivedAfterTime +} + +if fromFollowees := ${fromFollowees}; fromFollowees != "" { + fromFolloweesBool, err := strconv.ParseBool(fromFollowees) + if err != nil { + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid fromFollowees") + return + } + params.FromFollowees = &fromFolloweesBool +} + +if orderBy := ${orderBy}; orderBy != "" { + params.OrderBy = controller.ListProjectsOrderBy(orderBy) +} + params.Pagination.Index = paramInt("pageIndex", firstPageIndex) params.Pagination.Size = paramInt("pageSize", defaultPageSize) if ok, msg := params.Validate(); !ok { diff --git a/spx-backend/cmd/spx-backend/get_user_#username.yap b/spx-backend/cmd/spx-backend/get_user_#username.yap new file mode 100644 index 000000000..78dd79e89 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_user_#username.yap @@ -0,0 +1,13 @@ +// Get user by username. +// +// Request: +// GET /user/:username + +ctx := &Context + +user, err := ctrl.GetUser(ctx.Context(), ${username}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json user diff --git a/spx-backend/cmd/spx-backend/get_user_#username_following.yap b/spx-backend/cmd/spx-backend/get_user_#username_following.yap new file mode 100644 index 000000000..2ef5a61f0 --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_user_#username_following.yap @@ -0,0 +1,21 @@ +// Check if a user is followed by the authenticated user. +// +// Request: +// GET /user/:username/following + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +isFollowing, err := ctrl.IsFollowingUser(ctx.Context(), ${username}) +if err != nil { + replyWithInnerError(ctx, err) + return +} +if isFollowing { + ctx.text 204, "", "" +} else { + replyWithCode(ctx, errorNotFound) +} diff --git a/spx-backend/cmd/spx-backend/get_users_list.yap b/spx-backend/cmd/spx-backend/get_users_list.yap new file mode 100644 index 000000000..a3258385d --- /dev/null +++ b/spx-backend/cmd/spx-backend/get_users_list.yap @@ -0,0 +1,38 @@ +// List users. +// +// Request: +// GET /users/list + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +params := controller.NewListUsersParams() + +if follower := ${follower}; follower != "" { + params.Follower = &follower +} + +if followee := ${followee}; followee != "" { + params.Followee = &followee +} + +if orderBy := ${orderBy}; orderBy != "" { + params.OrderBy = controller.ListUsersOrderBy(orderBy) +} + +params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) +params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +users, err := ctrl.ListUsers(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json users diff --git a/spx-backend/cmd/spx-backend/gop_autogen.go b/spx-backend/cmd/spx-backend/gop_autogen.go index 138c17bef..ed8f378e1 100644 --- a/spx-backend/cmd/spx-backend/gop_autogen.go +++ b/spx-backend/cmd/spx-backend/gop_autogen.go @@ -7,7 +7,6 @@ import ( "errors" "github.com/goplus/builder/spx-backend/internal/controller" "github.com/goplus/builder/spx-backend/internal/log" - "github.com/goplus/builder/spx-backend/internal/model" "github.com/goplus/yap" "net/http" "os" @@ -31,6 +30,14 @@ type delete_project_owner_name struct { yap.Handler *AppV2 } +type delete_project_owner_name_liking struct { + yap.Handler + *AppV2 +} +type delete_user_username_following struct { + yap.Handler + *AppV2 +} type get_asset_id struct { yap.Handler *AppV2 @@ -39,14 +46,38 @@ type get_assets_list struct { yap.Handler *AppV2 } +type get_project_release_owner_project_release struct { + yap.Handler + *AppV2 +} +type get_project_releases_list struct { + yap.Handler + *AppV2 +} type get_project_owner_name struct { yap.Handler *AppV2 } +type get_project_owner_name_liking struct { + yap.Handler + *AppV2 +} type get_projects_list struct { yap.Handler *AppV2 } +type get_user_username struct { + yap.Handler + *AppV2 +} +type get_user_username_following struct { + yap.Handler + *AppV2 +} +type get_users_list struct { + yap.Handler + *AppV2 +} type get_util_upinfo struct { yap.Handler *AppV2 @@ -64,7 +95,7 @@ type post_asset struct { yap.Handler *AppV2 } -type post_asset_id_click struct { +type post_project_release struct { yap.Handler *AppV2 } @@ -72,6 +103,14 @@ type post_project struct { yap.Handler *AppV2 } +type post_project_owner_name_liking struct { + yap.Handler + *AppV2 +} +type post_user_username_following struct { + yap.Handler + *AppV2 +} type post_util_fileurls struct { yap.Handler *AppV2 @@ -88,6 +127,10 @@ type put_project_owner_name struct { yap.Handler *AppV2 } +type put_user struct { + yap.Handler + *AppV2 +} //line cmd/spx-backend/main.yap:26 func (this *AppV2) MainEntry() { //line cmd/spx-backend/main.yap:26:1 @@ -145,7 +188,7 @@ func (this *AppV2) MainEntry() { } } func (this *AppV2) Main() { - yap.Gopt_AppV2_Main(this, new(delete_asset_id), new(delete_project_owner_name), new(get_asset_id), new(get_assets_list), new(get_project_owner_name), new(get_projects_list), new(get_util_upinfo), new(post_aigc_matting), new(post_asset), new(post_asset_id_click), new(post_project), new(post_util_fileurls), new(post_util_fmtcode), new(put_asset_id), new(put_project_owner_name)) + yap.Gopt_AppV2_Main(this, new(delete_asset_id), new(delete_project_owner_name), new(delete_project_owner_name_liking), new(delete_user_username_following), new(get_asset_id), new(get_assets_list), new(get_project_release_owner_project_release), new(get_project_releases_list), new(get_project_owner_name), new(get_project_owner_name_liking), new(get_projects_list), new(get_user_username), new(get_user_username_following), new(get_users_list), new(get_util_upinfo), new(post_aigc_matting), new(post_asset), new(post_project_release), new(post_project), new(post_project_owner_name_liking), new(post_user_username_following), new(post_util_fileurls), new(post_util_fmtcode), new(put_asset_id), new(put_project_owner_name), new(put_user)) } //line cmd/spx-backend/delete_asset_#id.yap:6 func (this *delete_asset_id) Main(_gop_arg0 *yap.Context) { @@ -162,7 +205,7 @@ func (this *delete_asset_id) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/delete_asset_#id.yap:12:1 - this.Json__1(nil) + this.Text__0(204, "", "") } func (this *delete_asset_id) Classfname() string { return "delete_asset_#id" @@ -182,11 +225,65 @@ func (this *delete_project_owner_name) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/delete_project_#owner_#name.yap:12:1 - this.Json__1(nil) + this.Text__0(204, "", "") } func (this *delete_project_owner_name) Classfname() string { return "delete_project_#owner_#name" } +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:6 +func (this *delete_project_owner_name_liking) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:8:1 + if +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:9:1 + return + } +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:12:1 + if +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:12:1 + err := this.ctrl.UnlikeProject(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("name")); err != nil { +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:14:1 + return + } +//line cmd/spx-backend/delete_project_#owner_#name_liking.yap:16:1 + this.Text__0(204, "", "") +} +func (this *delete_project_owner_name_liking) Classfname() string { + return "delete_project_#owner_#name_liking" +} +//line cmd/spx-backend/delete_user_#username_following.yap:6 +func (this *delete_user_username_following) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/delete_user_#username_following.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/delete_user_#username_following.yap:8:1 + if +//line cmd/spx-backend/delete_user_#username_following.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/delete_user_#username_following.yap:9:1 + return + } +//line cmd/spx-backend/delete_user_#username_following.yap:12:1 + if +//line cmd/spx-backend/delete_user_#username_following.yap:12:1 + err := this.ctrl.UnfollowUser(ctx.Context(), this.Gop_Env("username")); err != nil { +//line cmd/spx-backend/delete_user_#username_following.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/delete_user_#username_following.yap:14:1 + return + } +//line cmd/spx-backend/delete_user_#username_following.yap:16:1 + this.Text__0(204, "", "") +} +func (this *delete_user_username_following) Classfname() string { + return "delete_user_#username_following" +} //line cmd/spx-backend/get_asset_#id.yap:6 func (this *get_asset_id) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -207,125 +304,177 @@ func (this *get_asset_id) Main(_gop_arg0 *yap.Context) { func (this *get_asset_id) Classfname() string { return "get_asset_#id" } -//line cmd/spx-backend/get_assets_list.yap:13 +//line cmd/spx-backend/get_assets_list.yap:10 func (this *get_assets_list) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) -//line cmd/spx-backend/get_assets_list.yap:13:1 +//line cmd/spx-backend/get_assets_list.yap:10:1 ctx := &this.Context -//line cmd/spx-backend/get_assets_list.yap:15:1 +//line cmd/spx-backend/get_assets_list.yap:12:1 user, _ := controller.UserFromContext(ctx.Context()) +//line cmd/spx-backend/get_assets_list.yap:13:1 + params := controller.NewListAssetsParams() +//line cmd/spx-backend/get_assets_list.yap:15:1 + if +//line cmd/spx-backend/get_assets_list.yap:15:1 + keyword := this.Gop_Env("keyword"); keyword != "" { //line cmd/spx-backend/get_assets_list.yap:16:1 - params := &controller.ListAssetsParams{} -//line cmd/spx-backend/get_assets_list.yap:18:1 - params.Keyword = this.Gop_Env("keyword") -//line cmd/spx-backend/get_assets_list.yap:20:1 + params.Keyword = &keyword + } +//line cmd/spx-backend/get_assets_list.yap:19:1 switch -//line cmd/spx-backend/get_assets_list.yap:20:1 +//line cmd/spx-backend/get_assets_list.yap:19:1 owner := this.Gop_Env("owner"); owner { -//line cmd/spx-backend/get_assets_list.yap:21:1 +//line cmd/spx-backend/get_assets_list.yap:20:1 case "": -//line cmd/spx-backend/get_assets_list.yap:22:1 +//line cmd/spx-backend/get_assets_list.yap:21:1 if user == nil { -//line cmd/spx-backend/get_assets_list.yap:23:1 +//line cmd/spx-backend/get_assets_list.yap:22:1 replyWithCode(ctx, errorUnauthorized) -//line cmd/spx-backend/get_assets_list.yap:24:1 +//line cmd/spx-backend/get_assets_list.yap:23:1 return } +//line cmd/spx-backend/get_assets_list.yap:25:1 + params.Owner = &user.Username //line cmd/spx-backend/get_assets_list.yap:26:1 - params.Owner = &user.Name -//line cmd/spx-backend/get_assets_list.yap:27:1 case "*": -//line cmd/spx-backend/get_assets_list.yap:28:1 +//line cmd/spx-backend/get_assets_list.yap:27:1 params.Owner = nil -//line cmd/spx-backend/get_assets_list.yap:29:1 +//line cmd/spx-backend/get_assets_list.yap:28:1 default: -//line cmd/spx-backend/get_assets_list.yap:30:1 +//line cmd/spx-backend/get_assets_list.yap:29:1 params.Owner = &owner } -//line cmd/spx-backend/get_assets_list.yap:33:1 +//line cmd/spx-backend/get_assets_list.yap:32:1 if +//line cmd/spx-backend/get_assets_list.yap:32:1 + typeParam := ctx.Param("type"); typeParam != "" { //line cmd/spx-backend/get_assets_list.yap:33:1 + params.Type = &typeParam + } +//line cmd/spx-backend/get_assets_list.yap:36:1 + if +//line cmd/spx-backend/get_assets_list.yap:36:1 category := this.Gop_Env("category"); category != "" { -//line cmd/spx-backend/get_assets_list.yap:34:1 +//line cmd/spx-backend/get_assets_list.yap:37:1 params.Category = &category } -//line cmd/spx-backend/get_assets_list.yap:37:1 +//line cmd/spx-backend/get_assets_list.yap:40:1 if -//line cmd/spx-backend/get_assets_list.yap:37:1 - assetTypeParam := this.Gop_Env("assetType"); assetTypeParam != "" { -//line cmd/spx-backend/get_assets_list.yap:38:1 - assetTypeInt, err := strconv.Atoi(assetTypeParam) -//line cmd/spx-backend/get_assets_list.yap:39:1 - if err != nil { //line cmd/spx-backend/get_assets_list.yap:40:1 - replyWithCode(ctx, errorInvalidArgs) + filesHash := this.Gop_Env("filesHash"); filesHash != "" { //line cmd/spx-backend/get_assets_list.yap:41:1 - return - } -//line cmd/spx-backend/get_assets_list.yap:43:1 - assetType := model.AssetType(assetTypeInt) + params.FilesHash = &filesHash + } +//line cmd/spx-backend/get_assets_list.yap:44:1 + if //line cmd/spx-backend/get_assets_list.yap:44:1 - params.AssetType = &assetType + visibility := this.Gop_Env("visibility"); visibility != "" { +//line cmd/spx-backend/get_assets_list.yap:45:1 + params.Visibility = &visibility } -//line cmd/spx-backend/get_assets_list.yap:47:1 +//line cmd/spx-backend/get_assets_list.yap:48:1 if -//line cmd/spx-backend/get_assets_list.yap:47:1 - filesHash := this.Gop_Env("filesHash"); filesHash != "" { //line cmd/spx-backend/get_assets_list.yap:48:1 - params.FilesHash = &filesHash + orderBy := this.Gop_Env("orderBy"); orderBy != "" { +//line cmd/spx-backend/get_assets_list.yap:49:1 + params.OrderBy = controller.ListAssetsOrderBy(orderBy) } -//line cmd/spx-backend/get_assets_list.yap:51:1 - if -//line cmd/spx-backend/get_assets_list.yap:51:1 - isPublicParam := this.Gop_Env("isPublic"); isPublicParam != "" { //line cmd/spx-backend/get_assets_list.yap:52:1 - isPublicInt, err := strconv.Atoi(isPublicParam) + params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) //line cmd/spx-backend/get_assets_list.yap:53:1 - if err != nil { + params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) //line cmd/spx-backend/get_assets_list.yap:54:1 - replyWithCode(ctx, errorInvalidArgs) + if +//line cmd/spx-backend/get_assets_list.yap:54:1 + ok, msg := params.Validate(); !ok { //line cmd/spx-backend/get_assets_list.yap:55:1 - return - } -//line cmd/spx-backend/get_assets_list.yap:57:1 - isPublic := model.IsPublic(isPublicInt) -//line cmd/spx-backend/get_assets_list.yap:58:1 - params.IsPublic = &isPublic + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/get_assets_list.yap:56:1 + return } +//line cmd/spx-backend/get_assets_list.yap:59:1 + assets, err := this.ctrl.ListAssets(ctx.Context(), params) +//line cmd/spx-backend/get_assets_list.yap:60:1 + if err != nil { //line cmd/spx-backend/get_assets_list.yap:61:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_assets_list.yap:62:1 + return + } +//line cmd/spx-backend/get_assets_list.yap:64:1 + this.Json__1(assets) +} +func (this *get_assets_list) Classfname() string { + return "get_assets_list" +} +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:6 +func (this *get_project_release_owner_project_release) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:8:1 + projectRelease, err := this.ctrl.GetProjectRelease(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("project"), this.Gop_Env("release")) +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:9:1 + if err != nil { +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:10:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:11:1 + return + } +//line cmd/spx-backend/get_project-release_#owner_#project_#release.yap:13:1 + this.Json__1(projectRelease) +} +func (this *get_project_release_owner_project_release) Classfname() string { + return "get_project-release_#owner_#project_#release" +} +//line cmd/spx-backend/get_project-releases_list.yap:10 +func (this *get_project_releases_list) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_project-releases_list.yap:10:1 + ctx := &this.Context +//line cmd/spx-backend/get_project-releases_list.yap:12:1 + params := controller.NewListProjectReleasesParams() +//line cmd/spx-backend/get_project-releases_list.yap:14:1 if -//line cmd/spx-backend/get_assets_list.yap:61:1 +//line cmd/spx-backend/get_project-releases_list.yap:14:1 + projectFullName := this.Gop_Env("projectFullName"); projectFullName != "" { +//line cmd/spx-backend/get_project-releases_list.yap:15:1 + params.ProjectFullName = &projectFullName + } +//line cmd/spx-backend/get_project-releases_list.yap:18:1 + if +//line cmd/spx-backend/get_project-releases_list.yap:18:1 orderBy := this.Gop_Env("orderBy"); orderBy != "" { -//line cmd/spx-backend/get_assets_list.yap:62:1 - params.OrderBy = controller.ListAssetsOrderBy(orderBy) +//line cmd/spx-backend/get_project-releases_list.yap:19:1 + params.OrderBy = controller.ListProjectReleasesOrderBy(orderBy) } -//line cmd/spx-backend/get_assets_list.yap:65:1 +//line cmd/spx-backend/get_project-releases_list.yap:22:1 params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) -//line cmd/spx-backend/get_assets_list.yap:66:1 +//line cmd/spx-backend/get_project-releases_list.yap:23:1 params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) -//line cmd/spx-backend/get_assets_list.yap:67:1 +//line cmd/spx-backend/get_project-releases_list.yap:24:1 if -//line cmd/spx-backend/get_assets_list.yap:67:1 +//line cmd/spx-backend/get_project-releases_list.yap:24:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/get_assets_list.yap:68:1 +//line cmd/spx-backend/get_project-releases_list.yap:25:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/get_assets_list.yap:69:1 +//line cmd/spx-backend/get_project-releases_list.yap:26:1 return } -//line cmd/spx-backend/get_assets_list.yap:72:1 - assets, err := this.ctrl.ListAssets(ctx.Context(), params) -//line cmd/spx-backend/get_assets_list.yap:73:1 +//line cmd/spx-backend/get_project-releases_list.yap:29:1 + projectReleases, err := this.ctrl.ListProjectReleases(ctx.Context(), params) +//line cmd/spx-backend/get_project-releases_list.yap:30:1 if err != nil { -//line cmd/spx-backend/get_assets_list.yap:74:1 +//line cmd/spx-backend/get_project-releases_list.yap:31:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/get_assets_list.yap:75:1 +//line cmd/spx-backend/get_project-releases_list.yap:32:1 return } -//line cmd/spx-backend/get_assets_list.yap:77:1 - this.Json__1(assets) +//line cmd/spx-backend/get_project-releases_list.yap:34:1 + this.Json__1(projectReleases) } -func (this *get_assets_list) Classfname() string { - return "get_assets_list" +func (this *get_project_releases_list) Classfname() string { + return "get_project-releases_list" } //line cmd/spx-backend/get_project_#owner_#name.yap:6 func (this *get_project_owner_name) Main(_gop_arg0 *yap.Context) { @@ -347,6 +496,39 @@ func (this *get_project_owner_name) Main(_gop_arg0 *yap.Context) { func (this *get_project_owner_name) Classfname() string { return "get_project_#owner_#name" } +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:6 +func (this *get_project_owner_name_liking) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:8:1 + if +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:9:1 + return + } +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:12:1 + hasLiked, err := this.ctrl.HasLikedProject(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("name")) +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:13:1 + if err != nil { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:14:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:15:1 + return + } +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:17:1 + if hasLiked { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:18:1 + ctx.Text__0(204, "", "") + } else { +//line cmd/spx-backend/get_project_#owner_#name_liking.yap:20:1 + replyWithCode(ctx, errorNotFound) + } +} +func (this *get_project_owner_name_liking) Classfname() string { + return "get_project_#owner_#name_liking" +} //line cmd/spx-backend/get_projects_list.yap:13 func (this *get_projects_list) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -355,77 +537,267 @@ func (this *get_projects_list) Main(_gop_arg0 *yap.Context) { //line cmd/spx-backend/get_projects_list.yap:15:1 user, _ := controller.UserFromContext(ctx.Context()) //line cmd/spx-backend/get_projects_list.yap:16:1 - params := &controller.ListProjectsParams{} + params := controller.NewListProjectsParams() //line cmd/spx-backend/get_projects_list.yap:18:1 - if + switch //line cmd/spx-backend/get_projects_list.yap:18:1 - isPublicParam := this.Gop_Env("isPublic"); isPublicParam != "" { + owner := this.Gop_Env("owner"); owner { //line cmd/spx-backend/get_projects_list.yap:19:1 - isPublicInt, err := strconv.Atoi(isPublicParam) + case "": //line cmd/spx-backend/get_projects_list.yap:20:1 - if err != nil { + if user == nil { //line cmd/spx-backend/get_projects_list.yap:21:1 - replyWithCode(ctx, errorInvalidArgs) + replyWithCode(ctx, errorUnauthorized) //line cmd/spx-backend/get_projects_list.yap:22:1 return } //line cmd/spx-backend/get_projects_list.yap:24:1 - isPublic := model.IsPublic(isPublicInt) + params.Owner = &user.Username //line cmd/spx-backend/get_projects_list.yap:25:1 - params.IsPublic = &isPublic - } -//line cmd/spx-backend/get_projects_list.yap:28:1 - switch + case "*": +//line cmd/spx-backend/get_projects_list.yap:26:1 + params.Owner = nil +//line cmd/spx-backend/get_projects_list.yap:27:1 + default: //line cmd/spx-backend/get_projects_list.yap:28:1 - owner := this.Gop_Env("owner"); owner { -//line cmd/spx-backend/get_projects_list.yap:29:1 - case "": -//line cmd/spx-backend/get_projects_list.yap:30:1 - if user == nil { + params.Owner = &owner + } //line cmd/spx-backend/get_projects_list.yap:31:1 - replyWithCode(ctx, errorUnauthorized) + if +//line cmd/spx-backend/get_projects_list.yap:31:1 + remixedFrom := this.Gop_Env("remixedFrom"); remixedFrom != "" { //line cmd/spx-backend/get_projects_list.yap:32:1 - return - } -//line cmd/spx-backend/get_projects_list.yap:34:1 - params.Owner = &user.Name + params.RemixedFrom = &remixedFrom + } //line cmd/spx-backend/get_projects_list.yap:35:1 - case "*": + if +//line cmd/spx-backend/get_projects_list.yap:35:1 + keyword := this.Gop_Env("keyword"); keyword != "" { //line cmd/spx-backend/get_projects_list.yap:36:1 - params.Owner = nil -//line cmd/spx-backend/get_projects_list.yap:37:1 - default: -//line cmd/spx-backend/get_projects_list.yap:38:1 - params.Owner = &owner + params.Keyword = &keyword + } +//line cmd/spx-backend/get_projects_list.yap:39:1 + if +//line cmd/spx-backend/get_projects_list.yap:39:1 + visibility := this.Gop_Env("visibility"); visibility != "" { +//line cmd/spx-backend/get_projects_list.yap:40:1 + params.Visibility = &visibility } -//line cmd/spx-backend/get_projects_list.yap:41:1 - params.Pagination.Index = this.ParamInt("pageIndex", firstPageIndex) -//line cmd/spx-backend/get_projects_list.yap:42:1 - params.Pagination.Size = this.ParamInt("pageSize", defaultPageSize) //line cmd/spx-backend/get_projects_list.yap:43:1 if //line cmd/spx-backend/get_projects_list.yap:43:1 - ok, msg := params.Validate(); !ok { + liker := this.Gop_Env("liker"); liker != "" { //line cmd/spx-backend/get_projects_list.yap:44:1 + params.Liker = &liker + } +//line cmd/spx-backend/get_projects_list.yap:47:1 + if +//line cmd/spx-backend/get_projects_list.yap:47:1 + createdAfter := this.Gop_Env("createdAfter"); createdAfter != "" { +//line cmd/spx-backend/get_projects_list.yap:48:1 + createdAfterTime, err := time.Parse(time.RFC3339Nano, createdAfter) +//line cmd/spx-backend/get_projects_list.yap:49:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:50:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid createdAfter") +//line cmd/spx-backend/get_projects_list.yap:51:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:53:1 + params.CreatedAfter = &createdAfterTime + } +//line cmd/spx-backend/get_projects_list.yap:56:1 + if +//line cmd/spx-backend/get_projects_list.yap:56:1 + likesReceivedAfter := this.Gop_Env("likesReceivedAfter"); likesReceivedAfter != "" { +//line cmd/spx-backend/get_projects_list.yap:57:1 + likesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, likesReceivedAfter) +//line cmd/spx-backend/get_projects_list.yap:58:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:59:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid likesReceivedAfter") +//line cmd/spx-backend/get_projects_list.yap:60:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:62:1 + params.LikesReceivedAfter = &likesReceivedAfterTime + } +//line cmd/spx-backend/get_projects_list.yap:65:1 + if +//line cmd/spx-backend/get_projects_list.yap:65:1 + remixesReceivedAfter := this.Gop_Env("remixesReceivedAfter"); remixesReceivedAfter != "" { +//line cmd/spx-backend/get_projects_list.yap:66:1 + remixesReceivedAfterTime, err := time.Parse(time.RFC3339Nano, remixesReceivedAfter) +//line cmd/spx-backend/get_projects_list.yap:67:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:68:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid remixesReceivedAfter") +//line cmd/spx-backend/get_projects_list.yap:69:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:71:1 + params.RemixesReceivedAfter = &remixesReceivedAfterTime + } +//line cmd/spx-backend/get_projects_list.yap:74:1 + if +//line cmd/spx-backend/get_projects_list.yap:74:1 + fromFollowees := this.Gop_Env("fromFollowees"); fromFollowees != "" { +//line cmd/spx-backend/get_projects_list.yap:75:1 + fromFolloweesBool, err := strconv.ParseBool(fromFollowees) +//line cmd/spx-backend/get_projects_list.yap:76:1 + if err != nil { +//line cmd/spx-backend/get_projects_list.yap:77:1 + replyWithCodeMsg(ctx, errorInvalidArgs, "invalid fromFollowees") +//line cmd/spx-backend/get_projects_list.yap:78:1 + return + } +//line cmd/spx-backend/get_projects_list.yap:80:1 + params.FromFollowees = &fromFolloweesBool + } +//line cmd/spx-backend/get_projects_list.yap:83:1 + if +//line cmd/spx-backend/get_projects_list.yap:83:1 + orderBy := this.Gop_Env("orderBy"); orderBy != "" { +//line cmd/spx-backend/get_projects_list.yap:84:1 + params.OrderBy = controller.ListProjectsOrderBy(orderBy) + } +//line cmd/spx-backend/get_projects_list.yap:87:1 + params.Pagination.Index = this.ParamInt("pageIndex", firstPageIndex) +//line cmd/spx-backend/get_projects_list.yap:88:1 + params.Pagination.Size = this.ParamInt("pageSize", defaultPageSize) +//line cmd/spx-backend/get_projects_list.yap:89:1 + if +//line cmd/spx-backend/get_projects_list.yap:89:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/get_projects_list.yap:90:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/get_projects_list.yap:45:1 +//line cmd/spx-backend/get_projects_list.yap:91:1 return } -//line cmd/spx-backend/get_projects_list.yap:48:1 +//line cmd/spx-backend/get_projects_list.yap:94:1 projects, err := this.ctrl.ListProjects(ctx.Context(), params) -//line cmd/spx-backend/get_projects_list.yap:49:1 +//line cmd/spx-backend/get_projects_list.yap:95:1 if err != nil { -//line cmd/spx-backend/get_projects_list.yap:50:1 +//line cmd/spx-backend/get_projects_list.yap:96:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/get_projects_list.yap:51:1 +//line cmd/spx-backend/get_projects_list.yap:97:1 return } -//line cmd/spx-backend/get_projects_list.yap:53:1 +//line cmd/spx-backend/get_projects_list.yap:99:1 this.Json__1(projects) } func (this *get_projects_list) Classfname() string { return "get_projects_list" } +//line cmd/spx-backend/get_user_#username.yap:6 +func (this *get_user_username) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_user_#username.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_user_#username.yap:8:1 + user, err := this.ctrl.GetUser(ctx.Context(), this.Gop_Env("username")) +//line cmd/spx-backend/get_user_#username.yap:9:1 + if err != nil { +//line cmd/spx-backend/get_user_#username.yap:10:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_user_#username.yap:11:1 + return + } +//line cmd/spx-backend/get_user_#username.yap:13:1 + this.Json__1(user) +} +func (this *get_user_username) Classfname() string { + return "get_user_#username" +} +//line cmd/spx-backend/get_user_#username_following.yap:6 +func (this *get_user_username_following) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_user_#username_following.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/get_user_#username_following.yap:8:1 + if +//line cmd/spx-backend/get_user_#username_following.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/get_user_#username_following.yap:9:1 + return + } +//line cmd/spx-backend/get_user_#username_following.yap:12:1 + isFollowing, err := this.ctrl.IsFollowingUser(ctx.Context(), this.Gop_Env("username")) +//line cmd/spx-backend/get_user_#username_following.yap:13:1 + if err != nil { +//line cmd/spx-backend/get_user_#username_following.yap:14:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_user_#username_following.yap:15:1 + return + } +//line cmd/spx-backend/get_user_#username_following.yap:17:1 + if isFollowing { +//line cmd/spx-backend/get_user_#username_following.yap:18:1 + ctx.Text__0(204, "", "") + } else { +//line cmd/spx-backend/get_user_#username_following.yap:20:1 + replyWithCode(ctx, errorNotFound) + } +} +func (this *get_user_username_following) Classfname() string { + return "get_user_#username_following" +} +//line cmd/spx-backend/get_users_list.yap:10 +func (this *get_users_list) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/get_users_list.yap:10:1 + ctx := &this.Context +//line cmd/spx-backend/get_users_list.yap:12:1 + params := controller.NewListUsersParams() +//line cmd/spx-backend/get_users_list.yap:14:1 + if +//line cmd/spx-backend/get_users_list.yap:14:1 + follower := this.Gop_Env("follower"); follower != "" { +//line cmd/spx-backend/get_users_list.yap:15:1 + params.Follower = &follower + } +//line cmd/spx-backend/get_users_list.yap:18:1 + if +//line cmd/spx-backend/get_users_list.yap:18:1 + followee := this.Gop_Env("followee"); followee != "" { +//line cmd/spx-backend/get_users_list.yap:19:1 + params.Followee = &followee + } +//line cmd/spx-backend/get_users_list.yap:22:1 + if +//line cmd/spx-backend/get_users_list.yap:22:1 + orderBy := this.Gop_Env("orderBy"); orderBy != "" { +//line cmd/spx-backend/get_users_list.yap:23:1 + params.OrderBy = controller.ListUsersOrderBy(orderBy) + } +//line cmd/spx-backend/get_users_list.yap:26:1 + params.Pagination.Index = ctx.ParamInt("pageIndex", firstPageIndex) +//line cmd/spx-backend/get_users_list.yap:27:1 + params.Pagination.Size = ctx.ParamInt("pageSize", defaultPageSize) +//line cmd/spx-backend/get_users_list.yap:28:1 + if +//line cmd/spx-backend/get_users_list.yap:28:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/get_users_list.yap:29:1 + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/get_users_list.yap:30:1 + return + } +//line cmd/spx-backend/get_users_list.yap:33:1 + users, err := this.ctrl.ListUsers(ctx.Context(), params) +//line cmd/spx-backend/get_users_list.yap:34:1 + if err != nil { +//line cmd/spx-backend/get_users_list.yap:35:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/get_users_list.yap:36:1 + return + } +//line cmd/spx-backend/get_users_list.yap:38:1 + this.Json__1(users) +} +func (this *get_users_list) Classfname() string { + return "get_users_list" +} //line cmd/spx-backend/get_util_upinfo.yap:6 func (this *get_util_upinfo) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -502,64 +874,85 @@ func (this *post_asset) Main(_gop_arg0 *yap.Context) { //line cmd/spx-backend/post_asset.yap:10:1 ctx := &this.Context //line cmd/spx-backend/post_asset.yap:12:1 - user, ok := ensureUser(ctx) + if +//line cmd/spx-backend/post_asset.yap:12:1 + _, ok := ensureUser(ctx); !ok { //line cmd/spx-backend/post_asset.yap:13:1 - if !ok { -//line cmd/spx-backend/post_asset.yap:14:1 return } +//line cmd/spx-backend/post_asset.yap:16:1 + params := &controller.CreateAssetParams{} //line cmd/spx-backend/post_asset.yap:17:1 - params := &controller.AddAssetParams{} -//line cmd/spx-backend/post_asset.yap:18:1 if !parseJSON(ctx, params) { -//line cmd/spx-backend/post_asset.yap:19:1 +//line cmd/spx-backend/post_asset.yap:18:1 return } -//line cmd/spx-backend/post_asset.yap:21:1 - params.Owner = user.Name -//line cmd/spx-backend/post_asset.yap:22:1 +//line cmd/spx-backend/post_asset.yap:20:1 if -//line cmd/spx-backend/post_asset.yap:22:1 +//line cmd/spx-backend/post_asset.yap:20:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/post_asset.yap:23:1 +//line cmd/spx-backend/post_asset.yap:21:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/post_asset.yap:24:1 +//line cmd/spx-backend/post_asset.yap:22:1 return } -//line cmd/spx-backend/post_asset.yap:27:1 - asset, err := this.ctrl.AddAsset(ctx.Context(), params) -//line cmd/spx-backend/post_asset.yap:28:1 +//line cmd/spx-backend/post_asset.yap:25:1 + asset, err := this.ctrl.CreateAsset(ctx.Context(), params) +//line cmd/spx-backend/post_asset.yap:26:1 if err != nil { -//line cmd/spx-backend/post_asset.yap:29:1 +//line cmd/spx-backend/post_asset.yap:27:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/post_asset.yap:30:1 +//line cmd/spx-backend/post_asset.yap:28:1 return } -//line cmd/spx-backend/post_asset.yap:32:1 - this.Json__1(asset) +//line cmd/spx-backend/post_asset.yap:30:1 + this.Json__0(201, asset) } func (this *post_asset) Classfname() string { return "post_asset" } -//line cmd/spx-backend/post_asset_#id_click.yap:6 -func (this *post_asset_id_click) Main(_gop_arg0 *yap.Context) { +//line cmd/spx-backend/post_project-release.yap:10 +func (this *post_project_release) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) -//line cmd/spx-backend/post_asset_#id_click.yap:6:1 +//line cmd/spx-backend/post_project-release.yap:10:1 ctx := &this.Context -//line cmd/spx-backend/post_asset_#id_click.yap:8:1 +//line cmd/spx-backend/post_project-release.yap:12:1 + if +//line cmd/spx-backend/post_project-release.yap:12:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/post_project-release.yap:13:1 + return + } +//line cmd/spx-backend/post_project-release.yap:16:1 + params := &controller.CreateProjectReleaseParams{} +//line cmd/spx-backend/post_project-release.yap:17:1 + if !parseJSON(ctx, params) { +//line cmd/spx-backend/post_project-release.yap:18:1 + return + } +//line cmd/spx-backend/post_project-release.yap:20:1 if -//line cmd/spx-backend/post_asset_#id_click.yap:8:1 - err := this.ctrl.IncreaseAssetClickCount(ctx.Context(), this.Gop_Env("id")); err != nil { -//line cmd/spx-backend/post_asset_#id_click.yap:9:1 +//line cmd/spx-backend/post_project-release.yap:20:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/post_project-release.yap:21:1 + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/post_project-release.yap:22:1 + return + } +//line cmd/spx-backend/post_project-release.yap:25:1 + projectRelease, err := this.ctrl.CreateProjectRelease(ctx.Context(), params) +//line cmd/spx-backend/post_project-release.yap:26:1 + if err != nil { +//line cmd/spx-backend/post_project-release.yap:27:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/post_asset_#id_click.yap:10:1 +//line cmd/spx-backend/post_project-release.yap:28:1 return } -//line cmd/spx-backend/post_asset_#id_click.yap:12:1 - this.Json__1(nil) +//line cmd/spx-backend/post_project-release.yap:30:1 + this.Json__0(201, projectRelease) } -func (this *post_asset_id_click) Classfname() string { - return "post_asset_#id_click" +func (this *post_project_release) Classfname() string { + return "post_project-release" } //line cmd/spx-backend/post_project.yap:10 func (this *post_project) Main(_gop_arg0 *yap.Context) { @@ -567,45 +960,97 @@ func (this *post_project) Main(_gop_arg0 *yap.Context) { //line cmd/spx-backend/post_project.yap:10:1 ctx := &this.Context //line cmd/spx-backend/post_project.yap:12:1 - user, ok := ensureUser(ctx) + if +//line cmd/spx-backend/post_project.yap:12:1 + _, ok := ensureUser(ctx); !ok { //line cmd/spx-backend/post_project.yap:13:1 - if !ok { -//line cmd/spx-backend/post_project.yap:14:1 return } +//line cmd/spx-backend/post_project.yap:16:1 + params := &controller.CreateProjectParams{} //line cmd/spx-backend/post_project.yap:17:1 - params := &controller.AddProjectParams{} -//line cmd/spx-backend/post_project.yap:18:1 if !parseJSON(ctx, params) { -//line cmd/spx-backend/post_project.yap:19:1 +//line cmd/spx-backend/post_project.yap:18:1 return } -//line cmd/spx-backend/post_project.yap:21:1 - params.Owner = user.Name -//line cmd/spx-backend/post_project.yap:22:1 +//line cmd/spx-backend/post_project.yap:20:1 if -//line cmd/spx-backend/post_project.yap:22:1 +//line cmd/spx-backend/post_project.yap:20:1 ok, msg := params.Validate(); !ok { -//line cmd/spx-backend/post_project.yap:23:1 +//line cmd/spx-backend/post_project.yap:21:1 replyWithCodeMsg(ctx, errorInvalidArgs, msg) -//line cmd/spx-backend/post_project.yap:24:1 +//line cmd/spx-backend/post_project.yap:22:1 return } -//line cmd/spx-backend/post_project.yap:27:1 - project, err := this.ctrl.AddProject(ctx.Context(), params) -//line cmd/spx-backend/post_project.yap:28:1 +//line cmd/spx-backend/post_project.yap:25:1 + project, err := this.ctrl.CreateProject(ctx.Context(), params) +//line cmd/spx-backend/post_project.yap:26:1 if err != nil { -//line cmd/spx-backend/post_project.yap:29:1 +//line cmd/spx-backend/post_project.yap:27:1 replyWithInnerError(ctx, err) -//line cmd/spx-backend/post_project.yap:30:1 +//line cmd/spx-backend/post_project.yap:28:1 return } -//line cmd/spx-backend/post_project.yap:32:1 - this.Json__1(project) +//line cmd/spx-backend/post_project.yap:30:1 + this.Json__0(201, project) } func (this *post_project) Classfname() string { return "post_project" } +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:6 +func (this *post_project_owner_name_liking) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:8:1 + if +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:9:1 + return + } +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:12:1 + if +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:12:1 + err := this.ctrl.LikeProject(ctx.Context(), this.Gop_Env("owner"), this.Gop_Env("name")); err != nil { +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:14:1 + return + } +//line cmd/spx-backend/post_project_#owner_#name_liking.yap:16:1 + this.Text__0(204, "", "") +} +func (this *post_project_owner_name_liking) Classfname() string { + return "post_project_#owner_#name_liking" +} +//line cmd/spx-backend/post_user_#username_following.yap:6 +func (this *post_user_username_following) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/post_user_#username_following.yap:6:1 + ctx := &this.Context +//line cmd/spx-backend/post_user_#username_following.yap:8:1 + if +//line cmd/spx-backend/post_user_#username_following.yap:8:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/post_user_#username_following.yap:9:1 + return + } +//line cmd/spx-backend/post_user_#username_following.yap:12:1 + if +//line cmd/spx-backend/post_user_#username_following.yap:12:1 + err := this.ctrl.FollowUser(ctx.Context(), this.Gop_Env("username")); err != nil { +//line cmd/spx-backend/post_user_#username_following.yap:13:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/post_user_#username_following.yap:14:1 + return + } +//line cmd/spx-backend/post_user_#username_following.yap:16:1 + this.Text__0(204, "", "") +} +func (this *post_user_username_following) Classfname() string { + return "post_user_#username_following" +} //line cmd/spx-backend/post_util_fileurls.yap:10 func (this *post_util_fileurls) Main(_gop_arg0 *yap.Context) { this.Handler.Main(_gop_arg0) @@ -637,7 +1082,7 @@ func (this *post_util_fileurls) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/post_util_fileurls.yap:26:1 - this.Json__1(fileURLs) + this.Json__0(201, fileURLs) } func (this *post_util_fileurls) Classfname() string { return "post_util_fileurls" @@ -673,7 +1118,7 @@ func (this *post_util_fmtcode) Main(_gop_arg0 *yap.Context) { return } //line cmd/spx-backend/post_util_fmtcode.yap:26:1 - this.Json__1(formattedCode) + this.Json__0(201, formattedCode) } func (this *post_util_fmtcode) Classfname() string { return "post_util_fmtcode" @@ -764,6 +1209,49 @@ func (this *put_project_owner_name) Main(_gop_arg0 *yap.Context) { func (this *put_project_owner_name) Classfname() string { return "put_project_#owner_#name" } +//line cmd/spx-backend/put_user.yap:10 +func (this *put_user) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line cmd/spx-backend/put_user.yap:10:1 + ctx := &this.Context +//line cmd/spx-backend/put_user.yap:12:1 + if +//line cmd/spx-backend/put_user.yap:12:1 + _, ok := ensureUser(ctx); !ok { +//line cmd/spx-backend/put_user.yap:13:1 + return + } +//line cmd/spx-backend/put_user.yap:16:1 + params := &controller.UpdateAuthedUserParams{} +//line cmd/spx-backend/put_user.yap:17:1 + if !parseJSON(ctx, params) { +//line cmd/spx-backend/put_user.yap:18:1 + return + } +//line cmd/spx-backend/put_user.yap:20:1 + if +//line cmd/spx-backend/put_user.yap:20:1 + ok, msg := params.Validate(); !ok { +//line cmd/spx-backend/put_user.yap:21:1 + replyWithCodeMsg(ctx, errorInvalidArgs, msg) +//line cmd/spx-backend/put_user.yap:22:1 + return + } +//line cmd/spx-backend/put_user.yap:25:1 + user, err := this.ctrl.UpdateAuthedUser(ctx.Context(), params) +//line cmd/spx-backend/put_user.yap:26:1 + if err != nil { +//line cmd/spx-backend/put_user.yap:27:1 + replyWithInnerError(ctx, err) +//line cmd/spx-backend/put_user.yap:28:1 + return + } +//line cmd/spx-backend/put_user.yap:30:1 + this.Json__1(user) +} +func (this *put_user) Classfname() string { + return "put_user" +} func main() { new(AppV2).Main() } diff --git a/spx-backend/cmd/spx-backend/middleware.go b/spx-backend/cmd/spx-backend/middleware.go index 03fcfa00b..96723c6fd 100644 --- a/spx-backend/cmd/spx-backend/middleware.go +++ b/spx-backend/cmd/spx-backend/middleware.go @@ -53,7 +53,7 @@ func NewUserMiddleware(ctrl *controller.Controller) func(next http.Handler) http authorization := r.Header.Get("Authorization") if authorization != "" { token := strings.TrimPrefix(authorization, "Bearer ") - user, err := ctrl.UserFromToken(token) + user, err := ctrl.UserFromToken(r.Context(), token) if err != nil { logger.Printf("failed to get user from token: %v", err) } else if user == nil { diff --git a/spx-backend/cmd/spx-backend/post_asset.yap b/spx-backend/cmd/spx-backend/post_asset.yap index de860ada5..7869bb616 100644 --- a/spx-backend/cmd/spx-backend/post_asset.yap +++ b/spx-backend/cmd/spx-backend/post_asset.yap @@ -1,4 +1,4 @@ -// Add an asset. +// Create an asset. // // Request: // POST /asset @@ -9,24 +9,22 @@ import ( ctx := &Context -user, ok := ensureUser(ctx) -if !ok { +if _, ok := ensureUser(ctx); !ok { return } -params := &controller.AddAssetParams{} +params := &controller.CreateAssetParams{} if !parseJSON(ctx, params) { return } -params.Owner = user.Name if ok, msg := params.Validate(); !ok { replyWithCodeMsg(ctx, errorInvalidArgs, msg) return } -asset, err := ctrl.AddAsset(ctx.Context(), params) +asset, err := ctrl.CreateAsset(ctx.Context(), params) if err != nil { replyWithInnerError(ctx, err) return } -json asset +json 201, asset diff --git a/spx-backend/cmd/spx-backend/post_asset_#id_click.yap b/spx-backend/cmd/spx-backend/post_asset_#id_click.yap deleted file mode 100644 index 5b6d15f5b..000000000 --- a/spx-backend/cmd/spx-backend/post_asset_#id_click.yap +++ /dev/null @@ -1,12 +0,0 @@ -// Increase the click count of an asset. -// -// Request: -// POST /asset/:id/click - -ctx := &Context - -if err := ctrl.IncreaseAssetClickCount(ctx.Context(), ${id}); err != nil { - replyWithInnerError(ctx, err) - return -} -json nil diff --git a/spx-backend/cmd/spx-backend/post_project-release.yap b/spx-backend/cmd/spx-backend/post_project-release.yap new file mode 100644 index 000000000..f412e260c --- /dev/null +++ b/spx-backend/cmd/spx-backend/post_project-release.yap @@ -0,0 +1,30 @@ +// Create a project release. +// +// Request: +// POST /project-release + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +params := &controller.CreateProjectReleaseParams{} +if !parseJSON(ctx, params) { + return +} +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +projectRelease, err := ctrl.CreateProjectRelease(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json 201, projectRelease diff --git a/spx-backend/cmd/spx-backend/post_project.yap b/spx-backend/cmd/spx-backend/post_project.yap index 5d9e269b4..bc5dd4a70 100644 --- a/spx-backend/cmd/spx-backend/post_project.yap +++ b/spx-backend/cmd/spx-backend/post_project.yap @@ -1,4 +1,4 @@ -// Add a project. +// Create a project. // // Request: // POST /project @@ -9,24 +9,22 @@ import ( ctx := &Context -user, ok := ensureUser(ctx) -if !ok { +if _, ok := ensureUser(ctx); !ok { return } -params := &controller.AddProjectParams{} +params := &controller.CreateProjectParams{} if !parseJSON(ctx, params) { return } -params.Owner = user.Name if ok, msg := params.Validate(); !ok { replyWithCodeMsg(ctx, errorInvalidArgs, msg) return } -project, err := ctrl.AddProject(ctx.Context(), params) +project, err := ctrl.CreateProject(ctx.Context(), params) if err != nil { replyWithInnerError(ctx, err) return } -json project +json 201, project diff --git a/spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap b/spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap new file mode 100644 index 000000000..54e54f3ff --- /dev/null +++ b/spx-backend/cmd/spx-backend/post_project_#owner_#name_liking.yap @@ -0,0 +1,16 @@ +// Like a project. +// +// Request: +// POST /project/:owner/:name/liking + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.LikeProject(ctx.Context(), ${owner}, ${name}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/post_user_#username_following.yap b/spx-backend/cmd/spx-backend/post_user_#username_following.yap new file mode 100644 index 000000000..19ee2e03d --- /dev/null +++ b/spx-backend/cmd/spx-backend/post_user_#username_following.yap @@ -0,0 +1,16 @@ +// Follow a user. +// +// Request: +// POST /user/:username/following + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +if err := ctrl.FollowUser(ctx.Context(), ${username}); err != nil { + replyWithInnerError(ctx, err) + return +} +text 204, "", "" diff --git a/spx-backend/cmd/spx-backend/post_util_fileurls.yap b/spx-backend/cmd/spx-backend/post_util_fileurls.yap index faa9203c2..6c0d555b0 100644 --- a/spx-backend/cmd/spx-backend/post_util_fileurls.yap +++ b/spx-backend/cmd/spx-backend/post_util_fileurls.yap @@ -23,4 +23,4 @@ if err != nil { replyWithInnerError(ctx, err) return } -json fileURLs +json 201, fileURLs diff --git a/spx-backend/cmd/spx-backend/post_util_fmtcode.yap b/spx-backend/cmd/spx-backend/post_util_fmtcode.yap index 89e2dd924..42d7b63ec 100644 --- a/spx-backend/cmd/spx-backend/post_util_fmtcode.yap +++ b/spx-backend/cmd/spx-backend/post_util_fmtcode.yap @@ -23,4 +23,4 @@ if err != nil { replyWithInnerError(ctx, err) return } -json formattedCode +json 201, formattedCode diff --git a/spx-backend/cmd/spx-backend/put_user.yap b/spx-backend/cmd/spx-backend/put_user.yap new file mode 100644 index 000000000..87fb7211c --- /dev/null +++ b/spx-backend/cmd/spx-backend/put_user.yap @@ -0,0 +1,30 @@ +// Update the authenticated user. +// +// Request: +// PUT /user + +import ( + "github.com/goplus/builder/spx-backend/internal/controller" +) + +ctx := &Context + +if _, ok := ensureUser(ctx); !ok { + return +} + +params := &controller.UpdateAuthedUserParams{} +if !parseJSON(ctx, params) { + return +} +if ok, msg := params.Validate(); !ok { + replyWithCodeMsg(ctx, errorInvalidArgs, msg) + return +} + +user, err := ctrl.UpdateAuthedUser(ctx.Context(), params) +if err != nil { + replyWithInnerError(ctx, err) + return +} +json user diff --git a/spx-backend/cmd/spx-backend/util.go b/spx-backend/cmd/spx-backend/util.go index 6dee9899d..8132b0c2e 100644 --- a/spx-backend/cmd/spx-backend/util.go +++ b/spx-backend/cmd/spx-backend/util.go @@ -6,12 +6,14 @@ import ( "io" "github.com/goplus/builder/spx-backend/internal/controller" + "github.com/goplus/builder/spx-backend/internal/log" "github.com/goplus/builder/spx-backend/internal/model" "github.com/goplus/yap" + "gorm.io/gorm" ) // ensureUser ensures the user is authenticated. -func ensureUser(ctx *yap.Context) (u *controller.User, ok bool) { +func ensureUser(ctx *yap.Context) (u *model.User, ok bool) { u, ok = controller.UserFromContext(ctx.Context()) if !ok { replyWithCode(ctx, errorUnauthorized) @@ -54,15 +56,17 @@ func replyWithCodeMsg(ctx *yap.Context, code errorCode, msg string) { // replyWithInnerError replies to the client with the inner error. func replyWithInnerError(ctx *yap.Context, err error) { switch { - case errors.Is(err, model.ErrExist): + case errors.Is(err, gorm.ErrDuplicatedKey): replyWithCode(ctx, errorInvalidArgs) case errors.Is(err, controller.ErrUnauthorized): replyWithCode(ctx, errorUnauthorized) case errors.Is(err, controller.ErrForbidden): replyWithCode(ctx, errorForbidden) - case errors.Is(err, controller.ErrNotExist), errors.Is(err, model.ErrNotExist): + case errors.Is(err, controller.ErrNotExist), errors.Is(err, gorm.ErrRecordNotFound): replyWithCode(ctx, errorNotFound) default: + logger := log.GetReqLogger(ctx.Context()) + logger.Printf("failed to handle request [%s %s]: %v", ctx.Method, ctx.URL, err) replyWithCode(ctx, errorUnknown) } } diff --git a/spx-backend/go.mod b/spx-backend/go.mod index d8c541c9b..43f3c6140 100644 --- a/spx-backend/go.mod +++ b/spx-backend/go.mod @@ -16,11 +16,12 @@ require ( ) require ( - github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/casdoor/casdoor-go-sdk v0.36.0 github.com/goplus/gop v1.2.6 github.com/qiniu/go-sdk/v7 v7.18.0 github.com/stretchr/testify v1.8.1 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.12 ) require ( @@ -31,6 +32,8 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/goplus/gogen v1.15.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect golang.org/x/net v0.22.0 // indirect diff --git a/spx-backend/go.sum b/spx-backend/go.sum index f8bb644a6..564621016 100644 --- a/spx-backend/go.sum +++ b/spx-backend/go.sum @@ -12,8 +12,6 @@ cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYE filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= -github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/aws/aws-sdk-go v1.49.0 h1:g9BkW1fo9GqKfwg2+zCD+TW/D36Ux+vtfJ8guF4AYmY= github.com/aws/aws-sdk-go v1.49.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= @@ -75,6 +73,7 @@ github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.8.0/go.mod h1:9JhgTzTaE31GZDpH/HSvHiRJrJ3iKAgqqH0Bl/Ocjdk= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= @@ -127,11 +126,14 @@ github.com/goplus/gop v1.2.6 h1:kog3c5Js+8EopqmI4+CwueXsqibnBwYVt5q5N7juRVY= github.com/goplus/gop v1.2.6/go.mod h1:uREWbR1MrFaviZ4Mbx4ZCcAYDoqzO0iv1Qo6Np0Xx4E= github.com/goplus/yap v0.8.2-0.20240602010842-f547c8e81317 h1:/zflZoZaow6d/LO7mEXX0b1WYzQsixeJ7yKn2dSKAB0= github.com/goplus/yap v0.8.2-0.20240602010842-f547c8e81317/go.mod h1:kuBnII1/HeQLFPhnIKbBItTHA/gKogjPSymrvYqvG2g= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -291,5 +293,10 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/spx-backend/init.sql b/spx-backend/init.sql deleted file mode 100644 index e6d97874b..000000000 --- a/spx-backend/init.sql +++ /dev/null @@ -1,42 +0,0 @@ -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for asset --- ---------------------------- -DROP TABLE IF EXISTS `asset`; -CREATE TABLE `asset` ( - `id` int NOT NULL AUTO_INCREMENT, - `c_time` datetime NULL DEFAULT NULL, - `u_time` datetime NULL DEFAULT NULL, - `display_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `owner` varchar(255) NULL DEFAULT NULL, - `category` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `asset_type` int NULL DEFAULT NULL, - `files` json NULL, - `files_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `preview` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, - `click_count` int NULL DEFAULT 0, - `is_public` tinyint NULL DEFAULT NULL, - `status` int NULL DEFAULT NULL, - PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for project --- ---------------------------- -DROP TABLE IF EXISTS `project`; -CREATE TABLE `project` ( - `id` int UNSIGNED NOT NULL AUTO_INCREMENT, - `c_time` datetime NULL DEFAULT NULL, - `u_time` datetime NULL DEFAULT NULL, - `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, - `owner` varchar(255) NULL DEFAULT NULL, - `version` int NOT NULL DEFAULT 1, - `files` json NULL, - `is_public` tinyint NULL DEFAULT NULL, - `status` int NULL DEFAULT NULL, - PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = DYNAMIC; - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/spx-backend/internal/controller/aigc_test.go b/spx-backend/internal/controller/aigc_test.go deleted file mode 100644 index 2b4a48560..000000000 --- a/spx-backend/internal/controller/aigc_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package controller - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMattingParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "https://example.com/image.jpg", - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyImageUrl", func(t *testing.T) { - params := &MattingParams{} - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing imageUrl", msg) - }) - - t.Run("InvalidImageUrl", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "example.com/image.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl", msg) - }) - - t.Run("InvalidImageUrl2", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "https://", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl", msg) - }) - - t.Run("InvalidImageUrl3", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "image.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl", msg) - }) - - t.Run("InvalidScheme", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "ftp://example.com/image.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: unsupported scheme", msg) - }) - - t.Run("LocalImageUrl", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://localhost:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LocalImageUrl2", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://127.0.0.1:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LocalImageUrl3", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://[::1]:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LanImageUrl1", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://192.168.0.1:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) - - t.Run("LanImageUrl2", func(t *testing.T) { - params := &MattingParams{ - ImageUrl: "http://[fe80::1]:8080/a.jpg", - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid imageUrl: private IP", msg) - }) -} diff --git a/spx-backend/internal/controller/asset.go b/spx-backend/internal/controller/asset.go index 510f09146..43156384c 100644 --- a/spx-backend/internal/controller/asset.go +++ b/spx-backend/internal/controller/asset.go @@ -2,202 +2,294 @@ package controller import ( "context" + "fmt" + "maps" "regexp" + "strconv" - "github.com/goplus/builder/spx-backend/internal/log" "github.com/goplus/builder/spx-backend/internal/model" ) +// AssetDTO is the DTO for assets. +type AssetDTO struct { + ModelDTO + + Owner string `json:"owner"` + DisplayName string `json:"displayName"` + Type string `json:"type"` + Category string `json:"category"` + Files model.FileCollection `json:"files"` + FilesHash string `json:"filesHash"` + Visibility string `json:"visibility"` +} + +// toAssetDTO converts the model asset to its DTO. +func toAssetDTO(mAsset model.Asset) AssetDTO { + return AssetDTO{ + ModelDTO: toModelDTO(mAsset.Model), + Owner: mAsset.Owner.Username, + DisplayName: mAsset.DisplayName, + Type: mAsset.Type.String(), + Category: mAsset.Category, + Files: mAsset.Files, + FilesHash: mAsset.FilesHash, + Visibility: mAsset.Visibility.String(), + } +} + // assetDisplayNameRE is the regular expression for asset display name. var assetDisplayNameRE = regexp.MustCompile(`^.{1,100}$`) // ensureAsset ensures the asset exists and the user has access to it. -func (ctrl *Controller) ensureAsset(ctx context.Context, id string, ownedOnly bool) (*model.Asset, error) { - logger := log.GetReqLogger(ctx) - - asset, err := model.AssetByID(ctx, ctrl.db, id) - if err != nil { - logger.Printf("failed to get asset: %v", err) - return nil, err +func (ctrl *Controller) ensureAsset(ctx context.Context, id int64, ownedOnly bool) (*model.Asset, error) { + var mAsset model.Asset + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Where("id = ?", id). + First(&mAsset). + Error; err != nil { + return nil, fmt.Errorf("failed to get asset: %w", err) } - if ownedOnly || asset.IsPublic == model.Personal { - if _, err := EnsureUser(ctx, asset.Owner); err != nil { + if ownedOnly || mAsset.Visibility == model.VisibilityPrivate { + if _, err := ensureUser(ctx, mAsset.OwnerID); err != nil { return nil, err } } - return asset, nil + return &mAsset, nil } -// GetAsset gets asset by id. -func (ctrl *Controller) GetAsset(ctx context.Context, id string) (*model.Asset, error) { - return ctrl.ensureAsset(ctx, id, false) +// CreateAssetParams holds parameters for creating an asset. +type CreateAssetParams struct { + DisplayName string `json:"displayName"` + Type string `json:"type"` + Category string `json:"category"` + Files model.FileCollection `json:"files"` + FilesHash string `json:"filesHash"` + Preview string `json:"preview"` + Visibility string `json:"visibility"` +} + +// Validate validates the parameters. +func (p *CreateAssetParams) Validate() (ok bool, msg string) { + if p.DisplayName == "" { + return false, "missing displayName" + } else if !assetDisplayNameRE.Match([]byte(p.DisplayName)) { + return false, "invalid displayName" + } + if model.ParseAssetType(p.Type).String() != p.Type { + return false, "invalid type" + } + if p.Category == "" { + return false, "missing category" + } + if p.FilesHash == "" { + return false, "missing filesHash" + } + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" + } + return true, "" +} + +// CreateAsset creates an asset. +func (ctrl *Controller) CreateAsset(ctx context.Context, params *CreateAssetParams) (*AssetDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + + mAsset := model.Asset{ + OwnerID: mUser.ID, + DisplayName: params.DisplayName, + Type: model.ParseAssetType(params.Type), + Category: params.Category, + Files: params.Files, + FilesHash: params.FilesHash, + Visibility: model.ParseVisibility(params.Visibility), + } + if err := ctrl.db.WithContext(ctx).Create(&mAsset).Error; err != nil { + return nil, fmt.Errorf("failed to create asset: %w", err) + } + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + First(&mAsset). + Error; err != nil { + return nil, fmt.Errorf("failed to get asset: %w", err) + } + assetDTO := toAssetDTO(mAsset) + return &assetDTO, nil } // ListAssetsOrderBy is the order by condition for listing assets. type ListAssetsOrderBy string -var ( - DefaultOrder ListAssetsOrderBy = "default" - TimeDesc ListAssetsOrderBy = "time" - ClickCountDesc ListAssetsOrderBy = "clickCount" +const ( + ListAssetsOrderByCreatedAt ListAssetsOrderBy = "createdAt" + ListAssetsOrderByUpdatedAt ListAssetsOrderBy = "updatedAt" ) +// IsValid reports whether the order by condition is valid. +func (ob ListAssetsOrderBy) IsValid() bool { + switch ob { + case ListAssetsOrderByCreatedAt, ListAssetsOrderByUpdatedAt: + return true + } + return false +} + // ListAssetsParams holds parameters for listing assets. type ListAssetsParams struct { - // Keyword is the keyword filter for the display name, applied only if non-empty. - Keyword string - - // Owner is the owner filter, applied only if non-nil. + // Keyword filters assets by display name pattern. + // + // Applied only if non-nil. + Keyword *string + + // Owner filters assets by owner's username. + // + // Applied only if non-nil. Owner *string - // Category is the category filter, applied only if non-nil. - Category *string + // Type filters assets by type. + // + // Applied only if non-nil. + Type *string - // AccessType is the access type filter, applied only if non-nil. - AssetType *model.AssetType + // Category filters assets by category. + // + // Applied only if non-nil. + Category *string - // FilesHash is the files hash filter, applied only if non-nil. + // FilesHash filters assets by files hash. + // + // Applied only if non-nil. FilesHash *string - // IsPublic is the visibility filter, applied only if non-nil. - IsPublic *model.IsPublic + // Visibility filters assets by visibility. + // + // Applied only if non-nil. + Visibility *string - // OrderBy is the order by condition. + // OrderBy indicates the field by which to order the results. OrderBy ListAssetsOrderBy + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder + // Pagination is the pagination information. - Pagination model.Pagination + Pagination Pagination +} + +// NewListAssetsParams creates a new ListAssetsParams. +func NewListAssetsParams() *ListAssetsParams { + return &ListAssetsParams{ + OrderBy: ListAssetsOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } } // Validate validates the parameters. func (p *ListAssetsParams) Validate() (ok bool, msg string) { + if p.Type != nil && model.ParseAssetType(*p.Type).String() != *p.Type { + return false, "invalid type" + } + if p.Visibility != nil && model.ParseVisibility(*p.Visibility).String() != *p.Visibility { + return false, "invalid visibility" + } + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } return true, "" } // ListAssets lists assets. -func (ctrl *Controller) ListAssets(ctx context.Context, params *ListAssetsParams) (*model.ByPage[model.Asset], error) { - logger := log.GetReqLogger(ctx) - +func (ctrl *Controller) ListAssets(ctx context.Context, params *ListAssetsParams) (*ByPage[AssetDTO], error) { // Ensure non-owners can only see public assets. - if user, ok := UserFromContext(ctx); !ok || params.Owner == nil || user.Name != *params.Owner { - public := model.Public - params.IsPublic = &public + if mUser, ok := UserFromContext(ctx); !ok || params.Owner == nil || *params.Owner != mUser.Username { + public := model.VisibilityPublic.String() + params.Visibility = &public } - var wheres []model.FilterCondition - if params.Keyword != "" { - wheres = append(wheres, model.FilterCondition{Column: "display_name", Operation: "LIKE", Value: "%" + params.Keyword + "%"}) + query := ctrl.db.WithContext(ctx).Model(&model.Asset{}) + if params.Keyword != nil { + query = query.Where("asset.display_name LIKE ?", "%"+*params.Keyword+"%") } if params.Owner != nil { - wheres = append(wheres, model.FilterCondition{Column: "owner", Operation: "=", Value: *params.Owner}) + query = query.Joins("JOIN user ON user.id = asset.owner_id").Where("user.username = ?", *params.Owner) } - if params.Category != nil { - wheres = append(wheres, model.FilterCondition{Column: "category", Operation: "=", Value: *params.Category}) + if params.Type != nil { + query = query.Where("asset.type = ?", model.ParseAssetType(*params.Type)) } - if params.AssetType != nil { - wheres = append(wheres, model.FilterCondition{Column: "asset_type", Operation: "=", Value: *params.AssetType}) + if params.Category != nil { + query = query.Where("asset.category = ?", *params.Category) } if params.FilesHash != nil { - wheres = append(wheres, model.FilterCondition{Column: "files_hash", Operation: "=", Value: *params.FilesHash}) + query = query.Where("asset.files_hash = ?", *params.FilesHash) } - if params.IsPublic != nil { - wheres = append(wheres, model.FilterCondition{Column: "is_public", Operation: "=", Value: *params.IsPublic}) + if params.Visibility != nil { + query = query.Where("asset.visibility = ?", model.ParseVisibility(*params.Visibility)) } - - var orders []model.OrderByCondition switch params.OrderBy { - case TimeDesc: - orders = append(orders, model.OrderByCondition{Column: "c_time", Direction: "DESC"}) - case ClickCountDesc: - orders = append(orders, model.OrderByCondition{Column: "click_count", Direction: "DESC"}) + case ListAssetsOrderByCreatedAt: + query = query.Order(fmt.Sprintf("asset.created_at %s", params.SortOrder)) + case ListAssetsOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("asset.updated_at %s", params.SortOrder)) } - assets, err := model.ListAssets(ctx, ctrl.db, params.Pagination, wheres, orders) - if err != nil { - logger.Printf("failed to list assets : %v", err) - return nil, err + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count assets: %w", err) } - return assets, nil -} - -// AddAssetParams holds parameters for adding an asset. -type AddAssetParams struct { - DisplayName string `json:"displayName"` - Owner string `json:"owner"` - Category string `json:"category"` - AssetType model.AssetType `json:"assetType"` - Files model.FileCollection `json:"files"` - FilesHash string `json:"filesHash"` - Preview string `json:"preview"` - IsPublic model.IsPublic `json:"isPublic"` -} -// Validate validates the parameters. -func (p *AddAssetParams) Validate() (ok bool, msg string) { - if p.DisplayName == "" { - return false, "missing displayName" - } else if !assetDisplayNameRE.Match([]byte(p.DisplayName)) { - return false, "invalid displayName" - } - if p.Owner == "" { - return false, "missing owner" - } - if p.Category == "" { - return false, "missing category" + var mAssets []model.Asset + if err := query. + Preload("Owner"). + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mAssets). + Error; err != nil { + return nil, fmt.Errorf("failed to list assets: %w", err) } - switch p.AssetType { - case model.AssetTypeSprite, model.AssetTypeBackdrop, model.AssetTypeSound: - default: - return false, "invalid assetType" + assetDTOs := make([]AssetDTO, len(mAssets)) + for i, mAsset := range mAssets { + assetDTOs[i] = toAssetDTO(mAsset) } - if p.FilesHash == "" { - return false, "missing filesHash" - } - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" - } - return true, "" + return &ByPage[AssetDTO]{ + Total: total, + Data: assetDTOs, + }, nil } -// AddAsset adds an asset. -func (ctrl *Controller) AddAsset(ctx context.Context, params *AddAssetParams) (*model.Asset, error) { - logger := log.GetReqLogger(ctx) - - user, err := EnsureUser(ctx, params.Owner) +// GetAsset gets asset by id. +func (ctrl *Controller) GetAsset(ctx context.Context, id string) (*AssetDTO, error) { + mAssetID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid asset id: %w", err) } - - asset, err := model.AddAsset(ctx, ctrl.db, &model.Asset{ - DisplayName: params.DisplayName, - Owner: user.Name, - Category: params.Category, - AssetType: params.AssetType, - Files: params.Files, - FilesHash: params.FilesHash, - Preview: params.Preview, - IsPublic: params.IsPublic, - }) + mAsset, err := ctrl.ensureAsset(ctx, mAssetID, false) if err != nil { - logger.Printf("failed to add asset: %v", err) return nil, err } - return asset, nil + assetDTO := toAssetDTO(*mAsset) + return &assetDTO, nil } // UpdateAssetParams holds parameters for updating an asset. type UpdateAssetParams struct { DisplayName string `json:"displayName"` + Type string `json:"type"` Category string `json:"category"` - AssetType model.AssetType `json:"assetType"` Files model.FileCollection `json:"files"` FilesHash string `json:"filesHash"` - Preview string `json:"preview"` - IsPublic model.IsPublic `json:"isPublic"` + Visibility string `json:"visibility"` } // Validate validates the parameters. @@ -207,78 +299,71 @@ func (p *UpdateAssetParams) Validate() (ok bool, msg string) { } else if !assetDisplayNameRE.Match([]byte(p.DisplayName)) { return false, "invalid displayName" } + if model.ParseAssetType(p.Type).String() != p.Type { + return false, "invalid type" + } if p.Category == "" { return false, "missing category" } - switch p.AssetType { - case model.AssetTypeSprite, model.AssetTypeBackdrop, model.AssetTypeSound: - default: - return false, "invalid assetType" - } if p.FilesHash == "" { return false, "missing filesHash" } - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" } return true, "" } // UpdateAsset updates an asset. -func (ctrl *Controller) UpdateAsset(ctx context.Context, id string, updates *UpdateAssetParams) (*model.Asset, error) { - logger := log.GetReqLogger(ctx) - - asset, err := ctrl.ensureAsset(ctx, id, true) +func (ctrl *Controller) UpdateAsset(ctx context.Context, id string, params *UpdateAssetParams) (*AssetDTO, error) { + mAssetID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid asset id: %w", err) } - - updatedAsset, err := model.UpdateAssetByID(ctx, ctrl.db, asset.ID, &model.Asset{ - DisplayName: updates.DisplayName, - Category: updates.Category, - AssetType: updates.AssetType, - Files: updates.Files, - FilesHash: updates.FilesHash, - Preview: updates.Preview, - IsPublic: updates.IsPublic, - }) + mAsset, err := ctrl.ensureAsset(ctx, mAssetID, true) if err != nil { - logger.Printf("failed to update asset: %v", err) return nil, err } - return updatedAsset, nil -} - -// IncreaseAssetClickCount increases the click count of an asset. -func (ctrl *Controller) IncreaseAssetClickCount(ctx context.Context, id string) error { - logger := log.GetReqLogger(ctx) - - asset, err := ctrl.ensureAsset(ctx, id, false) - if err != nil { - return err + updates := map[string]any{} + if params.DisplayName != mAsset.DisplayName { + updates["display_name"] = params.DisplayName } - - if err := model.IncreaseAssetClickCount(ctx, ctrl.db, asset.ID); err != nil { - logger.Printf("failed to increase asset click count: %v", err) - return err + if params.Type != mAsset.Type.String() { + updates["type"] = model.ParseAssetType(params.Type) } - return nil + if params.Category != mAsset.Category { + updates["category"] = params.Category + } + if !maps.Equal(params.Files, mAsset.Files) { + updates["files"] = params.Files + } + if params.FilesHash != mAsset.FilesHash { + updates["files_hash"] = params.FilesHash + } + if params.Visibility != mAsset.Visibility.String() { + updates["visibility"] = model.ParseVisibility(params.Visibility) + } + if len(updates) > 0 { + if err := ctrl.db.WithContext(ctx).Model(mAsset).Updates(updates).Error; err != nil { + return nil, fmt.Errorf("failed to update asset: %w", err) + } + } + assetDTO := toAssetDTO(*mAsset) + return &assetDTO, nil } // DeleteAsset deletes an asset. func (ctrl *Controller) DeleteAsset(ctx context.Context, id string) error { - logger := log.GetReqLogger(ctx) - - asset, err := ctrl.ensureAsset(ctx, id, true) + mAssetID, err := strconv.ParseInt(id, 10, 64) if err != nil { - return err + return fmt.Errorf("invalid asset id: %w", err) } - - if err := model.DeleteAssetByID(ctx, ctrl.db, asset.ID); err != nil { - logger.Printf("failed to delete asset: %v", err) + mAsset, err := ctrl.ensureAsset(ctx, mAssetID, true) + if err != nil { return err } + if err := ctrl.db.WithContext(ctx).Delete(mAsset).Error; err != nil { + return fmt.Errorf("failed to delete asset: %w", err) + } return nil } diff --git a/spx-backend/internal/controller/asset_test.go b/spx-backend/internal/controller/asset_test.go deleted file mode 100644 index c78b319bf..000000000 --- a/spx-backend/internal/controller/asset_test.go +++ /dev/null @@ -1,899 +0,0 @@ -package controller - -import ( - "context" - "database/sql" - "strings" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/goplus/builder/spx-backend/internal/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestControllerEnsureAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - asset, err := ctrl.ensureAsset(ctx, "1", false) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - }) - - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.ensureAsset(ctx, "1", false) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - _, err = ctrl.ensureAsset(ctx, "1", false) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("NoUserWithPublicAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "is_public"}). - AddRow(1, "fake-asset", "fake-name", model.Public)) - asset, err := ctrl.ensureAsset(ctx, "1", false) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - }) - - t.Run("NoUserWithPublicAssetButCheckOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "is_public"}). - AddRow(1, "fake-asset", "fake-name", model.Public)) - _, err = ctrl.ensureAsset(ctx, "1", true) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("NoUserWithPersonalAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "is_public"}). - AddRow(1, "fake-asset", "fake-name", model.Personal)) - _, err = ctrl.ensureAsset(ctx, "1", false) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) -} - -func TestControllerGetAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - asset, err := ctrl.GetAsset(ctx, "1") - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - }) - - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.GetAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) -} - -func TestListAssetsParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) -} - -func TestControllerListAssets(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Personal, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Personal, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: TimeDesc, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY c_time DESC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) - }) - - t.Run("NoOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: ClickCountDesc, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY click_count DESC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) - }) - - t.Run("DifferentOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "another-fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \?`). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(1)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE display_name LIKE \? AND owner = \? AND category = \? AND asset_type = \? AND files_hash = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \? `). - WithArgs("%"+params.Keyword+"%", params.Owner, params.Category, model.AssetTypeSprite, params.FilesHash, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "another-fake-name")) - assets, err := ctrl.ListAssets(ctx, params) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "1", assets.Data[0].ID) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsCategory := "fake-category" - paramsAssetType := model.AssetTypeSprite - paramsFilesHash := "fake-files-hash" - paramsIsPublic := model.Personal - params := &ListAssetsParams{ - Keyword: "fake", - Owner: ¶msOwner, - Category: ¶msCategory, - AssetType: ¶msAssetType, - FilesHash: ¶msFilesHash, - IsPublic: ¶msIsPublic, - OrderBy: DefaultOrder, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - _, err = ctrl.ListAssets(ctx, params) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -func TestAddAssetParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyDisplayName", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing displayName", msg) - }) - - t.Run("InvalidDisplayName", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: strings.Repeat("fake-asset", 11), - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid displayName", msg) - }) - - t.Run("EmptyOwner", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing owner", msg) - }) - - t.Run("EmptyCategory", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing category", msg) - }) - - t.Run("InvalidAssetType", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: -1, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid assetType", msg) - }) - - t.Run("EmptyFilesHash", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing filesHash", msg) - }) - - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &AddAssetParams{ - DisplayName: "fake-display-name", - Owner: "fake-owner", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: -1, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerAddAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectExec(`INSERT INTO asset \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner"}). - AddRow(1, "fake-asset", "fake-name")) - asset, err := ctrl.AddAsset(ctx, params) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - _, err = ctrl.AddAsset(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "another-fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - _, err = ctrl.AddAsset(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - ctx := newContextWithTestUser(context.Background()) - params := &AddAssetParams{ - DisplayName: "fake-asset", - Owner: "fake-name", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - _, err = ctrl.AddAsset(ctx, params) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -func TestUpdateAssetParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyDisplayName", func(t *testing.T) { - params := &UpdateAssetParams{ - DisplayName: "", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing displayName", msg) - }) - - t.Run("InvalidDisplayName", func(t *testing.T) { - params := &UpdateAssetParams{ - DisplayName: strings.Repeat("fake-asset", 11), - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid displayName", msg) - }) - - t.Run("EmptyCategory", func(t *testing.T) { - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing category", msg) - }) - - t.Run("InvalidAssetType", func(t *testing.T) { - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: -1, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid assetType", msg) - }) - - t.Run("EmptyFilesHash", func(t *testing.T) { - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "", - Preview: "fake-preview", - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing filesHash", msg) - }) - - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: -1, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerUpdateAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), params.DisplayName, params.Category, params.AssetType, []byte("{}"), params.FilesHash, params.Preview, params.IsPublic, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Public)) - asset, err := ctrl.UpdateAsset(ctx, "1", params) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "1", asset.ID) - assert.Equal(t, model.Public, asset.IsPublic) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - _, err = ctrl.UpdateAsset(ctx, "1", params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "another-fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - _, err = ctrl.UpdateAsset(ctx, "1", params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.UpdateAsset(ctx, "1", params) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateAssetParams{ - DisplayName: "fake-asset", - Category: "fake-category", - AssetType: model.AssetTypeSprite, - Files: model.FileCollection{}, - FilesHash: "fake-files-hash", - Preview: "fake-preview", - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), params.DisplayName, params.Category, params.AssetType, []byte("{}"), params.FilesHash, params.Preview, params.IsPublic, "1"). - WillReturnError(sql.ErrConnDone) - _, err = ctrl.UpdateAsset(ctx, "1", params) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestControllerIncreaseAssetClickCount(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.NoError(t, err) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "another-fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - err = ctrl.IncreaseAssetClickCount(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestControllerDeleteAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = ctrl.DeleteAsset(ctx, "1") - require.NoError(t, err) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.DeleteAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "another-fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - err = ctrl.DeleteAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("NoAsset", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - err = ctrl.DeleteAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "display_name", "owner", "files", "files_hash", "is_public"}). - AddRow(1, "fake-asset", "fake-name", []byte("{}"), "fake-files-hash", model.Personal)) - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = ctrl.DeleteAsset(ctx, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/controller/controller.go b/spx-backend/internal/controller/controller.go index 801202ac6..dd2d95161 100644 --- a/spx-backend/internal/controller/controller.go +++ b/spx-backend/internal/controller/controller.go @@ -2,23 +2,27 @@ package controller import ( "context" - "database/sql" "errors" _ "image/png" "io/fs" "os" + "strconv" + "time" "github.com/casdoor/casdoor-go-sdk/casdoorsdk" _ "github.com/go-sql-driver/mysql" "github.com/goplus/builder/spx-backend/internal/aigc" "github.com/goplus/builder/spx-backend/internal/log" + "github.com/goplus/builder/spx-backend/internal/model" "github.com/joho/godotenv" _ "github.com/qiniu/go-cdk-driver/kodoblob" qiniuAuth "github.com/qiniu/go-sdk/v7/auth" qiniuLog "github.com/qiniu/x/log" + "gorm.io/gorm" ) var ( + ErrBadRequest = errors.New("bad request") ErrNotExist = errors.New("not exist") ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") @@ -32,7 +36,7 @@ type contextKey struct { // Controller is the controller for the service. type Controller struct { - db *sql.DB + db *gorm.DB kodo *kodoConfig aigcClient *aigc.AigcClient casdoorClient *casdoorsdk.Client @@ -48,9 +52,9 @@ func New(ctx context.Context) (*Controller, error) { } dsn := mustEnv(logger, "GOP_SPX_DSN") - db, err := sql.Open("mysql", dsn) + db, err := model.OpenDB(ctx, dsn, 0, 0) if err != nil { - logger.Printf("failed to connect sql: %v", err) + logger.Printf("failed to open database: %v", err) return nil, err } // TODO: Configure connection pool and timeouts. @@ -68,12 +72,12 @@ func New(ctx context.Context) (*Controller, error) { aigcClient := aigc.NewAigcClient(mustEnv(logger, "AIGC_ENDPOINT")) casdoorAuthConfig := &casdoorsdk.AuthConfig{ - Endpoint: os.Getenv("GOP_CASDOOR_ENDPOINT"), - ClientId: os.Getenv("GOP_CASDOOR_CLIENTID"), - ClientSecret: os.Getenv("GOP_CASDOOR_CLIENTSECRET"), - Certificate: os.Getenv("GOP_CASDOOR_CERTIFICATE"), - OrganizationName: os.Getenv("GOP_CASDOOR_ORGANIZATIONNAME"), - ApplicationName: os.Getenv("GOP_CASDOOR_APPLICATONNAME"), + Endpoint: mustEnv(logger, "GOP_CASDOOR_ENDPOINT"), + ClientId: mustEnv(logger, "GOP_CASDOOR_CLIENTID"), + ClientSecret: mustEnv(logger, "GOP_CASDOOR_CLIENTSECRET"), + Certificate: mustEnv(logger, "GOP_CASDOOR_CERTIFICATE"), + OrganizationName: mustEnv(logger, "GOP_CASDOOR_ORGANIZATIONNAME"), + ApplicationName: mustEnv(logger, "GOP_CASDOOR_APPLICATIONNAME"), } casdoorClient := casdoorsdk.NewClientWithConf(casdoorAuthConfig) @@ -101,3 +105,58 @@ func mustEnv(logger *qiniuLog.Logger, key string) string { } return value } + +// ModelDTO is the data transfer object for models. +type ModelDTO struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// toModelDTO converts the model to its DTO. +func toModelDTO(m model.Model) ModelDTO { + return ModelDTO{ + ID: strconv.FormatInt(m.ID, 10), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +// SortOrder is the sort order. +type SortOrder string + +const ( + SortOrderAsc SortOrder = "asc" + SortOrderDesc SortOrder = "desc" +) + +// IsValid reports whether the sort order is valid. +func (so SortOrder) IsValid() bool { + switch so { + case SortOrderAsc, SortOrderDesc: + return true + } + return false +} + +// Pagination is the pagination information. +type Pagination struct { + Index int + Size int +} + +// IsValid reports whether the pagination is valid. +func (p Pagination) IsValid() bool { + return p.Index >= 1 && p.Size >= 1 && p.Size <= 100 +} + +// Offset returns the calculated offset for DB query. +func (p Pagination) Offset() int { + return (p.Index - 1) * p.Size +} + +// ByPage is a generic struct for paginated data. +type ByPage[T any] struct { + Total int64 `json:"total"` + Data []T `json:"data"` +} diff --git a/spx-backend/internal/controller/controller_test.go b/spx-backend/internal/controller/controller_test.go deleted file mode 100644 index 1dafeda6a..000000000 --- a/spx-backend/internal/controller/controller_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setTestEnv(t *testing.T) { - t.Setenv("GOP_SPX_DSN", "root:root@tcp(mysql.example.com:3306)/builder?charset=utf8&parseTime=True") - t.Setenv("AIGC_ENDPOINT", "https://aigc.example.com") - - t.Setenv("KODO_AK", "fake-kodo-ak") - t.Setenv("KODO_SK", "fake-kodo-sk") - t.Setenv("KODO_BUCKET", "builder") - t.Setenv("KODO_BUCKET_REGION", "earth") - t.Setenv("KODO_BASE_URL", "https://kodo.example.com") - - t.Setenv("GOP_CASDOOR_ENDPOINT", "https://casdoor.example.com") - t.Setenv("GOP_CASDOOR_CLIENTID", "fake-client-id") - t.Setenv("GOP_CASDOOR_CLIENTSECRET", "fake-client-secret") - t.Setenv("GOP_CASDOOR_CERTIFICATE", `-----BEGIN CERTIFICATE----- -MIIDDDCCAfSgAwIBAgIPJTPotE6tfuppFEL608kZMA0GCSqGSIb3DQEBCwUAMCYx -DzANBgNVBAoTBkdvUGx1czETMBEGA1UEAxMKZ29wbHVzLm9yZzAgFw0yNDA1Mjcx -NjU3MjBaGA8yMTI0MDUwMzE2NTcyMFowJjEPMA0GA1UEChMGR29QbHVzMRMwEQYD -VQQDEwpnb3BsdXMub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -0YRGxHFxL8Nkw5EiUhKaqB6NV4NuE98Nj6YL941GPLBeQLijyfJ2ErpWIqz7Hwzn -NVo76qPSKi/BpGw+EL4EgiWokW+Aw4lElkd7gnd/annQ3W6AC9v0bN7FPZUfpFAH -NmhveFfM5K78GXQqCEFeV+kxqxym4qhtCCK7Tb0uJ1dalX2INiANMJ5YfKWmjrkG -Mh9fGzw0+SL9gB1gkREap1Z2vwRD2HEuQd4J5G1YFyFkrISbI4X8xTVn6dSPUbCk -2aM9Fmg/ExD9mt7m3Klxm6iOnLojs1cRlFcexQtTjPbTnyKWtWSzF9WIegZC7aL9 -MwKN0mqvXyE8wnv4M/l72wIDAQABozUwMzAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0l -BAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEA -TpnkzMMKWceK7LScYuIcXEqCOwmzhT6yNRNTHzplta7NznjCVCL+kGffRlUQhdsN -pVGKPWbisd6y43IaokrkWOEPwhdWNa2rK9U7zYhI+9gGbqO02gJUw/gFIEjwO84J -uPrfGhKFB+ckitTWslFGf1d/Dt/MYS544QlB06IW8f+AM7z0sohh5nGH8lQIOmLC -7zQdehTX3TTuydAgBmU7a8oiM10t4Xw5alyy23Mo1qzZaZ1qHc2feJ8r9w1codAt -3c666GQTCYyifHL1dV2F5KullyFZ86SiOw3VDQ9D34Yd6YMvtBMkAjh7UZxMvun2 -VTh1XIl/IELBoZ+rQXozGA== ------END CERTIFICATE-----`) - t.Setenv("GOP_CASDOOR_ORGANIZATIONNAME", "fake-organization") - t.Setenv("GOP_CASDOOR_APPLICATONNAME", "fake-application") -} - -func newTestController(t *testing.T) (*Controller, sqlmock.Sqlmock, error) { - setTestEnv(t) - - db, mock, err := sqlmock.New() - if err != nil { - return nil, nil, err - } - t.Cleanup(func() { - db.Close() - }) - - ctrl, err := New(context.Background()) - if err != nil { - return nil, nil, err - } - ctrl.db = db - return ctrl, mock, nil -} - -func TestNew(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - setTestEnv(t) - ctrl, err := New(context.Background()) - require.NoError(t, err) - require.NotNil(t, ctrl) - }) - - t.Run("InvalidDSN", func(t *testing.T) { - setTestEnv(t) - t.Setenv("GOP_SPX_DSN", "invalid-dsn") - ctrl, err := New(context.Background()) - require.Error(t, err) - assert.EqualError(t, err, "invalid DSN: missing the slash separating the database name") - require.Nil(t, ctrl) - }) -} diff --git a/spx-backend/internal/controller/project.go b/spx-backend/internal/controller/project.go index 73792ebe7..135a4f837 100644 --- a/spx-backend/internal/controller/project.go +++ b/spx-backend/internal/controller/project.go @@ -2,180 +2,761 @@ package controller import ( "context" + "database/sql" + "errors" + "fmt" + "maps" "regexp" + "strings" + "time" - "github.com/goplus/builder/spx-backend/internal/log" "github.com/goplus/builder/spx-backend/internal/model" + "gorm.io/gorm" ) -// projectNameRE is the regular expression for project name. -var projectNameRE = regexp.MustCompile(`^[\w-]{1,100}$`) +// ProjectDTO is the DTO for projects. +type ProjectDTO struct { + ModelDTO + + Owner string `json:"owner"` + RemixedFrom string `json:"remixedFrom,omitempty"` + Name string `json:"name"` + Version int `json:"version"` + Files model.FileCollection `json:"files"` + Visibility string `json:"visibility"` + Description string `json:"description"` + Instructions string `json:"instructions"` + Thumbnail string `json:"thumbnail"` + ViewCount int64 `json:"viewCount"` + LikeCount int64 `json:"likeCount"` + ReleaseCount int64 `json:"releaseCount"` + RemixCount int64 `json:"remixCount"` +} + +// toProjectDTO converts the model project to its DTO. +func toProjectDTO(mProject model.Project) ProjectDTO { + var remixedFrom string + if mProject.RemixedFromRelease != nil { + remixedFrom = fmt.Sprintf( + "%s/%s/%s", + mProject.RemixedFromRelease.Project.Owner.Username, + mProject.RemixedFromRelease.Project.Name, + mProject.RemixedFromRelease.Name, + ) + } + return ProjectDTO{ + ModelDTO: toModelDTO(mProject.Model), + Owner: mProject.Owner.Username, + RemixedFrom: remixedFrom, + Name: mProject.Name, + Version: mProject.Version, + Files: mProject.Files, + Visibility: mProject.Visibility.String(), + Description: mProject.Description, + Instructions: mProject.Instructions, + Thumbnail: mProject.Thumbnail, + ViewCount: mProject.ViewCount, + LikeCount: mProject.LikeCount, + ReleaseCount: mProject.ReleaseCount, + RemixCount: mProject.RemixCount, + } +} + +var ( + // projectNameRE is the regular expression for project name. + projectNameRE = regexp.MustCompile(`^[\w-]{1,100}$`) + + // projectFullNameRE is the regular expression for project full name. + projectFullNameRE = regexp.MustCompile(`^([\w-]{1,100})\/([\w-]{1,100})$`) +) // ensureProject ensures the project exists and the user has access to it. func (ctrl *Controller) ensureProject(ctx context.Context, owner, name string, ownedOnly bool) (*model.Project, error) { - logger := log.GetReqLogger(ctx) - - project, err := model.ProjectByOwnerAndName(ctx, ctrl.db, owner, name) - if err != nil { - logger.Printf("failed to get project %s/%s: %v", owner, name, err) - return nil, err + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return nil, fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) } - if ownedOnly || project.IsPublic == model.Personal { - if _, err := EnsureUser(ctx, project.Owner); err != nil { + if ownedOnly || mProject.Visibility == model.VisibilityPrivate { + if _, err := ensureUser(ctx, mProject.OwnerID); err != nil { return nil, err } } - return project, nil + return &mProject, nil } -// GetProject gets project by owner and name. -func (ctrl *Controller) GetProject(ctx context.Context, owner, name string) (*model.Project, error) { - return ctrl.ensureProject(ctx, owner, name, false) +// CreateProjectParams holds parameters for creating project. +type CreateProjectParams struct { + RemixSource string `json:"remixSource"` + Name string `json:"name"` + Files model.FileCollection `json:"files"` + Visibility string `json:"visibility"` + Description string `json:"description"` + Instructions string `json:"instructions"` + Thumbnail string `json:"thumbnail"` +} + +// Validate validates the parameters. +func (p *CreateProjectParams) Validate() (ok bool, msg string) { + if p.RemixSource != "" && + !projectFullNameRE.MatchString(p.RemixSource) && + !projectReleaseFullNameRE.MatchString(p.RemixSource) { + return false, "invalid remixSource" + } + if p.Name == "" { + return false, "missing name" + } else if !projectNameRE.Match([]byte(p.Name)) { + return false, "invalid name" + } + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" + } + return true, "" +} + +// CreateProject creates a project. +func (ctrl *Controller) CreateProject(ctx context.Context, params *CreateProjectParams) (*ProjectDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + + mProject := model.Project{ + OwnerID: mUser.ID, + Name: params.Name, + Version: 1, + Files: params.Files, + Visibility: model.ParseVisibility(params.Visibility), + Description: params.Description, + Instructions: params.Instructions, + Thumbnail: params.Thumbnail, + } + if params.RemixSource != "" { + parts := strings.Split(params.RemixSource, "/") + ownerUsername := parts[0] + projectName := parts[1] + + var mRemixSourceProject model.Project + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", ownerUsername). + Where("project.name = ?", projectName). + First(&mRemixSourceProject). + Error; err != nil { + return nil, fmt.Errorf("failed to get project %s/%s: %w", ownerUsername, projectName, err) + } + if mRemixSourceProject.Visibility == model.VisibilityPrivate { + return nil, ErrNotExist + } + + releaseQuery := ctrl.db.WithContext(ctx). + Model(&model.ProjectRelease{}). + Where("project_id = ?", mRemixSourceProject.ID) + if len(parts) == 3 { + releaseName := parts[2] + releaseQuery = releaseQuery.Where("name = ?", releaseName) + } else { + releaseQuery = releaseQuery.Order("created_at DESC") // latest release + } + + var mRemixSourceProjectRelease model.ProjectRelease + if err := releaseQuery.First(&mRemixSourceProjectRelease).Error; err != nil { + return nil, fmt.Errorf("failed to get release of project %s/%s: %w", ownerUsername, projectName, err) + } + + mProject.RemixedFromReleaseID = sql.NullInt64{ + Int64: mRemixSourceProjectRelease.ID, + Valid: true, + } + } + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&mProject).Error; err != nil { + return err + } + + userUpdates := map[string]any{ + "project_count": gorm.Expr("project_count + 1"), + } + if mProject.Visibility == model.VisibilityPublic { + userUpdates["public_project_count"] = gorm.Expr("public_project_count + 1") + } + if err := tx.Model(mUser).Updates(userUpdates).Error; err != nil { + return err + } + + if mProject.RemixedFromReleaseID.Valid { + if err := tx. + Model(&model.ProjectRelease{}). + Where("id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count + 1")). + Error; err != nil { + return err + } + if err := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.project_id = project.id"). + Where("project_release.id = ?", mProject.RemixedFromReleaseID.Int64). + Update("project.remix_count", gorm.Expr("project.remix_count + 1")). + Error; err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, fmt.Errorf("failed to create project: %w", err) + } + if err := ctrl.db.WithContext(ctx). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + First(&mProject). + Error; err != nil { + return nil, fmt.Errorf("failed to get project: %w", err) + } + projectDTO := toProjectDTO(mProject) + return &projectDTO, nil +} + +// ListProjectsOrderBy is the order by condition for listing projects. +type ListProjectsOrderBy string + +const ( + ListProjectsOrderByCreatedAt ListProjectsOrderBy = "createdAt" + ListProjectsOrderByUpdatedAt ListProjectsOrderBy = "updatedAt" + ListProjectsOrderByLikeCount ListProjectsOrderBy = "likeCount" + ListProjectsOrderByRemixCount ListProjectsOrderBy = "remixCount" + ListProjectsOrderByRecentLikeCount ListProjectsOrderBy = "recentLikeCount" + ListProjectsOrderByRecentRemixCount ListProjectsOrderBy = "recentRemixCount" +) + +// IsValid reports whether the order by condition is valid. +func (ob ListProjectsOrderBy) IsValid() bool { + switch ob { + case ListProjectsOrderByCreatedAt, + ListProjectsOrderByUpdatedAt, + ListProjectsOrderByLikeCount, + ListProjectsOrderByRemixCount, + ListProjectsOrderByRecentLikeCount, + ListProjectsOrderByRecentRemixCount: + return true + } + return false } // ListProjectsParams holds parameters for listing projects. type ListProjectsParams struct { - // Owner is the owner filter, applied only if non-nil. + // Owner filters projects by the owner's username. + // + // Applied only if non-nil. Owner *string - // IsPublic is the visibility filter, applied only if non-nil. - IsPublic *model.IsPublic + // RemixedFrom filters remixed projects by the full name of the source + // project or project release. + // + // Applied only if non-nil. + RemixedFrom *string + + // Keyword filters projects by name pattern. + // + // Applied only if non-nil. + Keyword *string + + // Visibility filters assets by visibility. + // + // Applied only if non-nil. + Visibility *string + + // Liked filters projects liked by the specified user. + // + // Applied only if non-nil. + Liker *string + + // CreatedAfter filters projects created after this timestamp. + // + // Applied only if non-nil. + CreatedAfter *time.Time + + // LikesReceivedAfter filters projects that gained new likes after this timestamp. + // + // Applied only if non-nil. + LikesReceivedAfter *time.Time + + // RemixesReceivedAfter filters projects that were remixed after this timestamp. + RemixesReceivedAfter *time.Time + + // FromFollowees indicates whether to include projects created by the + // authenticated user's followees. + // + // Applied only if non-nil. + FromFollowees *bool + + // OrderBy indicates the field by which to order the results. + OrderBy ListProjectsOrderBy + + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder // Pagination is the pagination information. - Pagination model.Pagination + Pagination Pagination +} + +// NewListProjectsParams creates a new ListProjectsParams. +func NewListProjectsParams() *ListProjectsParams { + return &ListProjectsParams{ + OrderBy: ListProjectsOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } } // Validate validates the parameters. func (p *ListProjectsParams) Validate() (ok bool, msg string) { + if p.RemixedFrom != nil && + !projectFullNameRE.MatchString(*p.RemixedFrom) && + !projectReleaseFullNameRE.MatchString(*p.RemixedFrom) { + return false, "invalid remixedFrom" + } + if p.Visibility != nil && model.ParseVisibility(*p.Visibility).String() != *p.Visibility { + return false, "invalid visibility" + } + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } return true, "" } // ListProjects lists projects. -func (ctrl *Controller) ListProjects(ctx context.Context, params *ListProjectsParams) (*model.ByPage[model.Project], error) { - logger := log.GetReqLogger(ctx) - +func (ctrl *Controller) ListProjects(ctx context.Context, params *ListProjectsParams) (*ByPage[ProjectDTO], error) { // Ensure non-owners can only see public projects. - if user, ok := UserFromContext(ctx); !ok || params.Owner == nil || user.Name != *params.Owner { - public := model.Public - params.IsPublic = &public + if mUser, ok := UserFromContext(ctx); !ok || params.Owner == nil || *params.Owner != mUser.Username { + public := model.VisibilityPublic.String() + params.Visibility = &public } - var wheres []model.FilterCondition + query := ctrl.db.WithContext(ctx). + Model(&model.Project{}). + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner") if params.Owner != nil { - wheres = append(wheres, model.FilterCondition{Column: "owner", Operation: "=", Value: *params.Owner}) + query = query.Joins("JOIN user ON user.id = project.owner_id").Where("user.username = ?", *params.Owner) } - if params.IsPublic != nil { - wheres = append(wheres, model.FilterCondition{Column: "is_public", Operation: "=", Value: *params.IsPublic}) + if params.RemixedFrom != nil { + parts := strings.Split(*params.RemixedFrom, "/") + ownerUsername := parts[0] + projectName := parts[1] + query = query. + Joins("JOIN project_release ON project_release.id = project.remixed_from_release_id"). + Joins("JOIN project AS remixed_from_project ON remixed_from_project.id = project_release.project_id"). + Joins("JOIN user AS remixed_from_user ON remixed_from_user.id = remixed_from_project.owner_id"). + Where("remixed_from_user.username = ?", ownerUsername). + Where("remixed_from_project.name = ?", projectName) + if len(parts) == 3 { + releaseName := parts[2] + query = query.Where("project_release.name = ?", releaseName) + } } - - projects, err := model.ListProjects(ctx, ctrl.db, params.Pagination, wheres, nil) - if err != nil { - logger.Printf("failed to list project: %v", err) - return nil, err + if params.Keyword != nil { + query = query.Where("project.name LIKE ?", "%"+*params.Keyword+"%") } - return projects, nil -} - -// AddProjectParams holds parameters for adding project. -type AddProjectParams struct { - Name string `json:"name"` - Owner string `json:"owner"` - Files model.FileCollection `json:"files"` - IsPublic model.IsPublic `json:"isPublic"` -} - -// Validate validates the parameters. -func (p *AddProjectParams) Validate() (ok bool, msg string) { - if p.Name == "" { - return false, "missing name" - } else if !projectNameRE.Match([]byte(p.Name)) { - return false, "invalid name" + if params.Visibility != nil { + query = query.Where("project.visibility = ?", model.ParseVisibility(*params.Visibility)) } - if p.Owner == "" { - return false, "missing owner" + if params.Liker != nil { + query = query. + Joins("JOIN user_project_relationship AS liker_relationship ON liker_relationship.project_id = project.id"). + Joins("JOIN user AS liker ON liker.id = liker_relationship.user_id"). + Where("liker.username = ?", *params.Liker). + Where("user_project_relationship.liked_at IS NOT NULL") } - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + if params.CreatedAfter != nil { + query = query.Where("project.created_at > ?", *params.CreatedAfter) + } + if params.LikesReceivedAfter != nil { + query = query. + Joins("JOIN user_project_relationship AS likes_after_relationship ON likes_after_relationship.project_id = project.id"). + Where("likes_after_relationship.liked_at > ?", *params.LikesReceivedAfter). + Group("project.id") + } + if params.RemixesReceivedAfter != nil { + query = query. + Joins("JOIN project_release ON project_release.project_id = project.id"). + Joins("JOIN project AS remixed_project ON remixed_project.remixed_from_release_id = project_release.id"). + Where("remixed_project.created_at > ?", *params.RemixesReceivedAfter). + Group("project.id") + } + if params.FromFollowees != nil && *params.FromFollowees { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + query = query. + Joins("JOIN user_relationship ON user_relationship.target_user_id = project.owner_id"). + Where("user_relationship.user_id = ?", mUser.ID). + Where("user_relationship.followed_at IS NOT NULL") + } + switch params.OrderBy { + case ListProjectsOrderByCreatedAt: + query = query.Order(fmt.Sprintf("project.created_at %s", params.SortOrder)) + case ListProjectsOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("project.updated_at %s", params.SortOrder)) + case ListProjectsOrderByLikeCount: + query = query.Order(fmt.Sprintf("project.like_count %s", params.SortOrder)) + case ListProjectsOrderByRemixCount: + query = query.Order(fmt.Sprintf("project.remix_count %s", params.SortOrder)) + case ListProjectsOrderByRecentLikeCount: + if params.LikesReceivedAfter == nil { + query = query.Order(fmt.Sprintf("project.like_count %s", params.SortOrder)) + break + } + query = query. + Joins("JOIN user_project_relationship AS recent_likes_relationship ON recent_likes_relationship.project_id = project.id"). + Where("recent_likes_relationship.liked_at > ?", *params.LikesReceivedAfter). + Group("project.id"). + Order("COUNT(recent_likes_relationship.id) " + string(params.SortOrder)) + case ListProjectsOrderByRecentRemixCount: + if params.RemixesReceivedAfter == nil { + query = query.Order(fmt.Sprintf("project.remix_count %s", params.SortOrder)) + break + } + query = query. + Joins("JOIN project AS recent_remixed_project ON recent_remixed_project.remixed_from_release_id = project_release.id"). + Where("recent_remixed_project.created_at > ?", *params.RemixesReceivedAfter). + Group("project.id"). + Order("COUNT(recent_remixed_project.id) " + string(params.SortOrder)) } - return true, "" -} -// AddProject adds a project. -func (ctrl *Controller) AddProject(ctx context.Context, params *AddProjectParams) (*model.Project, error) { - logger := log.GetReqLogger(ctx) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count projects: %w", err) + } - user, err := EnsureUser(ctx, params.Owner) - if err != nil { - return nil, err + var mProjects []model.Project + if err := query. + Preload("Owner"). + Preload("RemixedFromRelease.Project.Owner"). + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mProjects). + Error; err != nil { + return nil, fmt.Errorf("failed to list projects: %w", err) } + projectDTOs := make([]ProjectDTO, len(mProjects)) + for i, mProject := range mProjects { + projectDTOs[i] = toProjectDTO(mProject) + } + return &ByPage[ProjectDTO]{ + Total: total, + Data: projectDTOs, + }, nil +} - project, err := model.AddProject(ctx, ctrl.db, &model.Project{ - Name: params.Name, - Owner: user.Name, - Version: 1, - Files: params.Files, - IsPublic: params.IsPublic, - }) +// GetProject gets project by owner and name. +func (ctrl *Controller) GetProject(ctx context.Context, owner, name string) (*ProjectDTO, error) { + mProject, err := ctrl.ensureProject(ctx, owner, name, false) if err != nil { - logger.Printf("failed to add project: %v", err) return nil, err } - return project, nil + projectDTO := toProjectDTO(*mProject) + return &projectDTO, nil } // UpdateProjectParams holds parameters for updating project. type UpdateProjectParams struct { - Files model.FileCollection `json:"files"` - IsPublic model.IsPublic `json:"isPublic"` + Files model.FileCollection `json:"files"` + Visibility string `json:"visibility"` + Description string `json:"description"` + Instructions string `json:"instructions"` + Thumbnail string `json:"thumbnail"` } // Validate validates the parameters. func (p *UpdateProjectParams) Validate() (ok bool, msg string) { - switch p.IsPublic { - case model.Personal, model.Public: - default: - return false, "invalid isPublic" + if model.ParseVisibility(p.Visibility).String() != p.Visibility { + return false, "invalid visibility" } return true, "" } // UpdateProject updates a project. -func (ctrl *Controller) UpdateProject(ctx context.Context, owner, name string, params *UpdateProjectParams) (*model.Project, error) { - logger := log.GetReqLogger(ctx) +func (ctrl *Controller) UpdateProject(ctx context.Context, owner, name string, params *UpdateProjectParams) (*ProjectDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } - project, err := ctrl.ensureProject(ctx, owner, name, true) + mProject, err := ctrl.ensureProject(ctx, owner, name, true) if err != nil { return nil, err } + updates := map[string]any{} + if !maps.Equal(params.Files, mProject.Files) { + updates["files"] = params.Files + } + if params.Visibility != mProject.Visibility.String() { + updates["visibility"] = model.ParseVisibility(params.Visibility) + } + if params.Description != mProject.Description { + updates["description"] = params.Description + } + if params.Instructions != mProject.Instructions { + updates["instructions"] = params.Instructions + } + if params.Thumbnail != mProject.Thumbnail { + updates["thumbnail"] = params.Thumbnail + } + if len(updates) > 0 { + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx.Model(mProject).Updates(updates); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } - updatedProject, err := model.UpdateProjectByID(ctx, ctrl.db, project.ID, &model.Project{ - Version: project.Version + 1, - Files: params.Files, - IsPublic: params.IsPublic, - }) - if err != nil { - logger.Printf("failed to update project: %v", err) - return nil, err + userUpdates := map[string]any{} + if updates["visibility"] != nil { + switch params.Visibility { + case model.VisibilityPrivate.String(): + userUpdates["public_project_count"] = gorm.Expr("public_project_count - 1") + case model.VisibilityPublic.String(): + userUpdates["public_project_count"] = gorm.Expr("public_project_count + 1") + } + } + if len(userUpdates) > 0 { + if err := tx.Model(mUser).Updates(userUpdates).Error; err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, fmt.Errorf("failed to update project: %w", err) + } } - return updatedProject, nil + projectDTO := toProjectDTO(*mProject) + return &projectDTO, nil } // DeleteProject deletes a project. func (ctrl *Controller) DeleteProject(ctx context.Context, owner, name string) error { - logger := log.GetReqLogger(ctx) + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + + mProject, err := ctrl.ensureProject(ctx, owner, name, true) + if err != nil { + return err + } + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(mProject).Error; err != nil { + return err + } + + userUpdates := map[string]any{ + "project_count": gorm.Expr("project_count - 1"), + } + if mProject.Visibility == model.VisibilityPublic { + userUpdates["public_project_count"] = gorm.Expr("public_project_count - 1") + } + if err := tx.Model(mUser).Updates(userUpdates).Error; err != nil { + return err + } + + if mProject.RemixedFromReleaseID.Valid { + if err := tx. + Model(&model.ProjectRelease{}). + Where("id = ?", mProject.RemixedFromReleaseID.Int64). + Update("remix_count", gorm.Expr("remix_count - 1")). + Error; err != nil { + return err + } + if err := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.project_id = project.id"). + Where("project_release.id = ?", mProject.RemixedFromReleaseID.Int64). + Update("project.remix_count", gorm.Expr("project.remix_count - 1")). + Error; err != nil { + return err + } + } - project, err := ctrl.ensureProject(ctx, owner, name, true) + if err := tx. + Model(&model.User{}). + Joins("JOIN user_project_relationship ON user_project_relationship.user_id = user.id"). + Where("user_project_relationship.project_id = ?", mProject.ID). + Where("user_project_relationship.liked_at IS NOT NULL"). + Update("user.liked_project_count", gorm.Expr("user.liked_project_count - 1")). + Error; err != nil { + return err + } + if err := tx. + Where("project_id = ?", mProject.ID). + Delete(&model.UserProjectRelationship{}). + Error; err != nil { + return err + } + + if err := tx. + Model(&model.Project{}). + Joins("JOIN project_release ON project_release.id = project.remixed_from_release_id"). + Where("project_release.project_id = ?", mProject.ID). + Update("project.remixed_from_release_id", sql.NullInt64{}). + Error; err != nil { + return err + } + if err := tx. + Where("project_id = ?", mProject.ID). + Delete(&model.ProjectRelease{}). + Error; err != nil { + return err + } + + return nil + }); err != nil { + return fmt.Errorf("failed to delete project: %w", err) + } + return nil +} + +// LikeProject likes the specified project as the authenticated user. +func (ctrl *Controller) LikeProject(ctx context.Context, owner, name string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + mUserProjectRelationship, err := model.FirstOrCreateUserProjectRelationship(ctx, ctrl.db, mUser.ID, mProject.ID) if err != nil { return err } + if mUserProjectRelationship.LikedAt.Valid { + return nil + } - if err := model.DeleteProjectByID(ctx, ctrl.db, project.ID); err != nil { - logger.Printf("failed to delete project: %v", err) + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserProjectRelationship). + Update("liked_at", sql.NullTime{ + Time: time.Now().UTC(), + Valid: true, + }); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("liked_project_count", gorm.Expr("liked_project_count + 1")).Error; err != nil { + return err + } + if err := tx.Model(&mProject).Update("like_count", gorm.Expr("like_count + 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to like project: %w", err) + } + return nil +} + +// HasLikedProject checks if the authenticated user has liked the specified project. +func (ctrl *Controller) HasLikedProject(ctx context.Context, owner, name string) (bool, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return false, nil + } + + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return false, fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + if err := ctrl.db.WithContext(ctx). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("project_id = ?", mProject.ID). + Where("liked_at IS NOT NULL"). + First(&model.UserProjectRelationship{}). + Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, fmt.Errorf("failed to check if user %s has liked project %s/%s: %w", mUser.Username, owner, name, err) + } + return true, nil +} + +// UnlikeProject unlikes the specified project as the authenticated user. +func (ctrl *Controller) UnlikeProject(ctx context.Context, owner, name string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + + var mProject model.Project + if err := ctrl.db.WithContext(ctx). + Joins("JOIN user ON user.id = project.owner_id"). + Where("user.username = ?", owner). + Where("project.name = ?", name). + First(&mProject). + Error; err != nil { + return fmt.Errorf("failed to get project %s/%s: %w", owner, name, err) + } + + mUserProjectRelationship, err := model.FirstOrCreateUserProjectRelationship(ctx, ctrl.db, mUser.ID, mProject.ID) + if err != nil { return err } + if !mUserProjectRelationship.LikedAt.Valid { + return nil + } + + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserProjectRelationship). + Update("liked_at", sql.NullTime{}); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("liked_project_count", gorm.Expr("liked_project_count - 1")).Error; err != nil { + return err + } + if err := tx.Model(&mProject).Update("like_count", gorm.Expr("like_count - 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to unlike project: %w", err) + } return nil } diff --git a/spx-backend/internal/controller/project_release.go b/spx-backend/internal/controller/project_release.go new file mode 100644 index 000000000..9b369659f --- /dev/null +++ b/spx-backend/internal/controller/project_release.go @@ -0,0 +1,248 @@ +package controller + +import ( + "context" + "fmt" + "regexp" + + "github.com/goplus/builder/spx-backend/internal/model" + "gorm.io/gorm" +) + +// ProjectReleaseDTO is the DTO for project releases. +type ProjectReleaseDTO struct { + ModelDTO + + ProjectFullName string `json:"projectFullName"` + Name string `json:"name"` + Description string `json:"description"` + Files model.FileCollection `json:"files"` + Thumbnail string `json:"thumbnail"` + RemixCount int64 `json:"remixCount"` +} + +// toProjectReleaseDTO converts the model project release to its DTO. +func toProjectReleaseDTO(pr model.ProjectRelease) ProjectReleaseDTO { + return ProjectReleaseDTO{ + ModelDTO: toModelDTO(pr.Model), + ProjectFullName: fmt.Sprintf( + "%s/%s", + pr.Project.Owner.Username, + pr.Project.Name, + ), + Name: pr.Name, + Description: pr.Description, + Files: pr.Files, + Thumbnail: pr.Thumbnail, + RemixCount: pr.RemixCount, + } +} + +var ( + // projectReleaseNameRE is the regular expression for project release name. + projectReleaseNameRE = regexp.MustCompile(`^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + + // projectReleaseFullNameRE is the regular expression for project release full name. + projectReleaseFullNameRE = regexp.MustCompile(`^([\w-]{1,100})\/([\w-]{1,100})\/(v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)))?$`) +) + +// CreateProjectReleaseParams holds the parameters for creating a project release. +type CreateProjectReleaseParams struct { + ProjectFullName string `json:"projectFullName"` + Name string `json:"name"` + Description string `json:"description"` + Thumbnail string `json:"thumbnail"` +} + +// Validate validates the parameters. +func (p *CreateProjectReleaseParams) Validate() (ok bool, msg string) { + if !projectFullNameRE.MatchString(p.ProjectFullName) { + return false, "invalid projectFullName" + } + if !projectReleaseNameRE.MatchString(p.Name) { + return false, "invalid projectReleaseName" + } + return true, "" +} + +// CreateProjectRelease creates a project release. +func (ctrl *Controller) CreateProjectRelease(ctx context.Context, params *CreateProjectReleaseParams) (*ProjectReleaseDTO, error) { + projectFullNameMatches := projectFullNameRE.FindStringSubmatch(params.ProjectFullName) + projectOwnerUsername := projectFullNameMatches[1] + projectName := projectFullNameMatches[2] + + mProject, err := ctrl.ensureProject(ctx, projectOwnerUsername, projectName, true) + if err != nil { + return nil, err + } + + if params.Description == "" { + params.Description = mProject.Description + } + if params.Thumbnail == "" { + params.Thumbnail = mProject.Thumbnail + } + mProjectRelease := model.ProjectRelease{ + ProjectID: mProject.ID, + Name: params.Name, + Description: params.Description, + Files: mProject.Files, + Thumbnail: params.Thumbnail, + } + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&mProjectRelease).Error; err != nil { + return err + } + + if err := tx. + Model(&mProject). + Update("release_count", gorm.Expr("release_count + 1")). + Error; err != nil { + return err + } + + return nil + }); err != nil { + return nil, fmt.Errorf("failed to create project release: %w", err) + } + if err := ctrl.db.WithContext(ctx). + Preload("Project.Owner"). + First(&mProjectRelease). + Error; err != nil { + return nil, fmt.Errorf("failed to get project release: %w", err) + } + projectReleaseDTO := toProjectReleaseDTO(mProjectRelease) + return &projectReleaseDTO, nil +} + +// ListProjectReleasesOrderBy is the order by for listing project releases. +type ListProjectReleasesOrderBy string + +const ( + ListProjectReleasesOrderByCreatedAt ListProjectReleasesOrderBy = "createdAt" + ListProjectReleasesOrderByUpdatedAt ListProjectReleasesOrderBy = "updatedAt" + ListProjectReleasesOrderByRemixCount ListProjectReleasesOrderBy = "remixCount" +) + +// IsValid reports whether the order by condition is valid. +func (ob ListProjectReleasesOrderBy) IsValid() bool { + switch ob { + case ListProjectReleasesOrderByCreatedAt, + ListProjectReleasesOrderByUpdatedAt, + ListProjectReleasesOrderByRemixCount: + return true + } + return false +} + +// ListProjectReleasesParams holds the parameters for listing project releases. +type ListProjectReleasesParams struct { + // ProjectFullName filters releases by the full name of the associated project. + // + // Applied only if non-nil. + ProjectFullName *string + + // OrderBy indicates the field by which to order the results. + OrderBy ListProjectReleasesOrderBy + + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder + + // Pagination is the pagination information. + Pagination Pagination +} + +// NewListProjectReleasesParams creates a new ListProjectReleasesParams. +func NewListProjectReleasesParams() *ListProjectReleasesParams { + return &ListProjectReleasesParams{ + OrderBy: ListProjectReleasesOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } +} + +// Validate validates the parameters. +func (p *ListProjectReleasesParams) Validate() (ok bool, msg string) { + if p.ProjectFullName != nil && !projectFullNameRE.MatchString(*p.ProjectFullName) { + return false, "invalid projectFullName" + } + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } + return true, "" +} + +// ListProjectReleases lists project releases. +func (ctrl *Controller) ListProjectReleases(ctx context.Context, params *ListProjectReleasesParams) (*ByPage[ProjectReleaseDTO], error) { + query := ctrl.db.WithContext(ctx). + Model(&model.ProjectRelease{}). + Preload("Project.Owner"). + Joins("JOIN project ON project.id = project_release.project_id"). + Where("project.visibility = ?", model.VisibilityPublic) + if params.ProjectFullName != nil { + projectFullNameMatches := projectFullNameRE.FindStringSubmatch(*params.ProjectFullName) + projectOwnerUsername := projectFullNameMatches[1] + projectName := projectFullNameMatches[2] + query = query. + Joins("JOIN user AS project_owner ON project_owner.id = project.owner_id"). + Where("project_owner.username = ?", projectOwnerUsername). + Where("project.name = ?", projectName) + } + switch params.OrderBy { + case ListProjectReleasesOrderByCreatedAt: + query = query.Order(fmt.Sprintf("project_release.created_at %s", params.SortOrder)) + case ListProjectReleasesOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("project_release.updated_at %s", params.SortOrder)) + case ListProjectReleasesOrderByRemixCount: + query = query.Order(fmt.Sprintf("project_release.remix_count %s", params.SortOrder)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count project releases: %w", err) + } + + var mProjectReleases []model.ProjectRelease + if err := query. + Preload("Project.Owner"). + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mProjectReleases). + Error; err != nil { + return nil, fmt.Errorf("failed to list project releases: %w", err) + } + projectReleaseDTOs := make([]ProjectReleaseDTO, len(mProjectReleases)) + for i, mProjectRelease := range mProjectReleases { + projectReleaseDTOs[i] = toProjectReleaseDTO(mProjectRelease) + } + return &ByPage[ProjectReleaseDTO]{ + Total: total, + Data: projectReleaseDTOs, + }, nil +} + +// GetProjectRelease gets a project release. +func (ctrl *Controller) GetProjectRelease(ctx context.Context, projectOwnerUsername, projectName, projectReleaseName string) (*ProjectReleaseDTO, error) { + mProject, err := ctrl.ensureProject(ctx, projectOwnerUsername, projectName, false) + if err != nil { + return nil, err + } + + var mProjectRelease model.ProjectRelease + if err := ctrl.db.WithContext(ctx). + Preload("Project.Owner"). + Where("project_id = ?", mProject.ID). + Where("name = ?", projectReleaseName). + First(&mProjectRelease). + Error; err != nil { + return nil, fmt.Errorf("failed to get project release: %w", err) + } + projectReleaseDTO := toProjectReleaseDTO(mProjectRelease) + return &projectReleaseDTO, nil +} diff --git a/spx-backend/internal/controller/project_test.go b/spx-backend/internal/controller/project_test.go deleted file mode 100644 index 8a1a00cc6..000000000 --- a/spx-backend/internal/controller/project_test.go +++ /dev/null @@ -1,608 +0,0 @@ -package controller - -import ( - "context" - "database/sql" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/goplus/builder/spx-backend/internal/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestControllerEnsureProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - project, err := ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("NoUserWithPublicProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "is_public"}). - AddRow(1, "fake-project", "fake-name", model.Public)) - project, err := ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - }) - - t.Run("NoUserWithPublicProjectButCheckOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "is_public"}). - AddRow(1, "fake-project", "fake-name", model.Public)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", true) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("NoUserWithPersonalProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "is_public"}). - AddRow(1, "fake-project", "fake-name", model.Personal)) - _, err = ctrl.ensureProject(ctx, "fake-name", "fake-project", false) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) -} - -func TestControllerGetProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - project, err := ctrl.GetProject(ctx, "fake-name", "fake-project") - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.GetProject(ctx, "fake-name", "fake-project") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) -} - -func TestListProjectsParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) -} - -func TestControllerListProjects(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE owner = \? AND is_public = \? AND status != \?`). - WithArgs(params.Owner, model.Personal, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(params.Owner, model.Personal, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - projects, err := ctrl.ListProjects(ctx, params) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE owner = \? AND is_public = \? AND status != \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - projects, err := ctrl.ListProjects(ctx, params) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) - }) - - t.Run("NoOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE is_public = \? AND status != \?`). - WithArgs(model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - projects, err := ctrl.ListProjects(ctx, params) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) - }) - - t.Run("DifferentOwner", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "another-fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE owner = \? AND is_public = \? AND status != \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND is_public = \? AND status != \? ORDER BY id ASC LIMIT \?, \?`). - WithArgs(params.Owner, model.Public, model.StatusDeleted, 0, 10). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "another-fake-name")) - projects, err := ctrl.ListProjects(ctx, params) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "1", projects.Data[0].ID) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - ctx := newContextWithTestUser(context.Background()) - paramsOwner := "fake-name" - paramsIsPublic := model.Personal - params := &ListProjectsParams{ - Owner: ¶msOwner, - IsPublic: ¶msIsPublic, - Pagination: model.Pagination{Index: 1, Size: 10}, - } - _, err = ctrl.ListProjects(ctx, params) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -func TestAddProjectParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyName", func(t *testing.T) { - params := &AddProjectParams{ - Name: "", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing name", msg) - }) - - t.Run("InvalidName", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name@", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid name", msg) - }) - - t.Run("EmptyOwner", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing owner", msg) - }) - - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &AddProjectParams{ - Name: "fake-name", - Owner: "fake-owner", - Files: model.FileCollection{}, - IsPublic: -1, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerAddProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - mock.ExpectExec(`INSERT INTO project \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - project, err := ctrl.AddProject(ctx, params) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - }) - - t.Run("Exist", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - _, err = ctrl.AddProject(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrExist) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - _, err = ctrl.AddProject(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "another-fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - _, err = ctrl.AddProject(ctx, params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("ClosedDB", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.db.Close() - - ctx := newContextWithTestUser(context.Background()) - params := &AddProjectParams{ - Name: "fake-project", - Owner: "fake-name", - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - _, err = ctrl.AddProject(ctx, params) - require.Error(t, err) - assert.EqualError(t, err, "sql: database is closed") - }) -} - -func TestUpdateProjectParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Personal, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("InvalidIsPublic", func(t *testing.T) { - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: -1, - } - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "invalid isPublic", msg) - }) -} - -func TestControllerUpdateProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Personal)) - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), model.Public, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Public)) - project, err := ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "1", project.ID) - assert.Equal(t, model.Public, project.IsPublic) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Personal)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "another-fake-name", []byte("{}"), model.Personal)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - params := &UpdateProjectParams{ - Files: model.FileCollection{}, - IsPublic: model.Public, - } - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner", "files", "is_public"}). - AddRow(1, "fake-project", "fake-name", []byte("{}"), model.Personal)) - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), model.Public, "1"). - WillReturnError(sql.ErrConnDone) - _, err = ctrl.UpdateProject(ctx, "fake-name", "fake-project", params) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestControllerDeleteProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") - require.NoError(t, err) - }) - - t.Run("NoUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := context.Background() - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "another-fake-name")) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) - - t.Run("NoProject", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") - require.Error(t, err) - assert.ErrorIs(t, err, model.ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - ctrl, mock, err := newTestController(t) - require.NoError(t, err) - - ctx := newContextWithTestUser(context.Background()) - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "owner"}). - AddRow(1, "fake-project", "fake-name")) - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), model.StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = ctrl.DeleteProject(ctx, "fake-name", "fake-project") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/controller/user.go b/spx-backend/internal/controller/user.go index 512fd53f9..75ce36262 100644 --- a/spx-backend/internal/controller/user.go +++ b/spx-backend/internal/controller/user.go @@ -2,46 +2,363 @@ package controller import ( "context" + "database/sql" + "errors" "fmt" + "time" - "github.com/casdoor/casdoor-go-sdk/casdoorsdk" + "github.com/goplus/builder/spx-backend/internal/model" + "gorm.io/gorm" ) -// User is the user info. -type User casdoorsdk.User +// UserDTO is the DTO for users. +type UserDTO struct { + ModelDTO + + Username string `json:"username"` + Description string `json:"description"` + FollowerCount int64 `json:"followerCount"` + FollowingCount int64 `json:"followingCount"` + ProjectCount int64 `json:"projectCount"` + PublicProjectCount int64 `json:"publicProjectCount"` + LikedProjectCount int64 `json:"likedProjectCount"` +} + +// toUserDTO converts the model user to its DTO. +func toUserDTO(mUser model.User) UserDTO { + return UserDTO{ + ModelDTO: toModelDTO(mUser.Model), + Username: mUser.Username, + Description: mUser.Description, + FollowerCount: mUser.FollowerCount, + FollowingCount: mUser.FollowingCount, + ProjectCount: mUser.ProjectCount, + PublicProjectCount: mUser.PublicProjectCount, + LikedProjectCount: mUser.LikedProjectCount, + } +} // userContextKey is the context key for user. var userContextKey = &contextKey{"user"} // NewContextWithUser creates a new context with user. -func NewContextWithUser(ctx context.Context, user *User) context.Context { - return context.WithValue(ctx, userContextKey, user) +func NewContextWithUser(ctx context.Context, mUser *model.User) context.Context { + return context.WithValue(ctx, userContextKey, mUser) } // UserFromContext gets user from context. -func UserFromContext(ctx context.Context) (user *User, ok bool) { - user, ok = ctx.Value(userContextKey).(*User) +func UserFromContext(ctx context.Context) (mUser *model.User, ok bool) { + mUser, ok = ctx.Value(userContextKey).(*model.User) return } -// EnsureUser ensures there is a user in the context and it matches the expected user. -func EnsureUser(ctx context.Context, expectedUser string) (*User, error) { - user, ok := UserFromContext(ctx) +// ensureUser ensures there is a user in the context and it matches the expected +// user. +func ensureUser(ctx context.Context, expectedUserID int64) (*model.User, error) { + mUser, ok := UserFromContext(ctx) if !ok { return nil, ErrUnauthorized } - if user.Name != expectedUser { + if mUser.ID != expectedUserID { return nil, ErrForbidden } - return user, nil + return mUser, nil } // UserFromToken gets user from the provided JWT token. -func (ctrl *Controller) UserFromToken(token string) (*User, error) { +func (ctrl *Controller) UserFromToken(ctx context.Context, token string) (*model.User, error) { claims, err := ctrl.casdoorClient.ParseJwtToken(token) if err != nil { return nil, fmt.Errorf("ctrl.casdoorClient.ParseJwtToken failed: %w", err) } - user := User(claims.User) - return &user, nil + mUser, err := model.FirstOrCreateUser(ctx, ctrl.db, claims.Name) + if err != nil { + return nil, err + } + return mUser, nil +} + +// ListUsersOrderBy is the order by condition for listing users. +type ListUsersOrderBy string + +const ( + ListUsersOrderByCreatedAt ListUsersOrderBy = "createdAt" + ListUsersOrderByUpdatedAt ListUsersOrderBy = "updatedAt" + ListUsersOrderByFollowedAt ListUsersOrderBy = "followedAt" +) + +// IsValid reports whether the order by condition is valid. +func (ob ListUsersOrderBy) IsValid() bool { + switch ob { + case ListUsersOrderByCreatedAt, ListUsersOrderByUpdatedAt, ListUsersOrderByFollowedAt: + return true + } + return false +} + +// ListUsersParams holds parameters for listing users. +type ListUsersParams struct { + // Follower filters users who are being followed by the specified user. + // + // Applied only if non-nil. + Follower *string + + // Followee filters users who are following the specified user. + // + // Applied only if non-nil. + Followee *string + + // OrderBy indicates the field by which to order the results. + OrderBy ListUsersOrderBy + + // SortOrder indicates the order in which to sort the results. + SortOrder SortOrder + + // Pagination is the pagination information. + Pagination Pagination +} + +// NewListUsersParams creates a new ListUsersParams. +func NewListUsersParams() *ListUsersParams { + return &ListUsersParams{ + OrderBy: ListUsersOrderByCreatedAt, + SortOrder: SortOrderDesc, + Pagination: Pagination{Index: 1, Size: 20}, + } +} + +// Validate validates the parameters. +func (p *ListUsersParams) Validate() (ok bool, msg string) { + if !p.OrderBy.IsValid() { + return false, "invalid orderBy" + } + if !p.SortOrder.IsValid() { + return false, "invalid sortOrder" + } + if !p.Pagination.IsValid() { + return false, "invalid pagination" + } + return true, "" +} + +// ListUsers retrieves a paginated list of users with optional filtering and ordering. +func (ctrl *Controller) ListUsers(ctx context.Context, params *ListUsersParams) (*ByPage[UserDTO], error) { + query := ctrl.db.WithContext(ctx).Model(&model.User{}) + joinedTables := map[string]struct{}{} + if params.Follower != nil { + query = query.Joins("JOIN user_relationship ON user_relationship.target_user_id = user.id"). + Where("user_relationship.user_id = ?", *params.Follower) + joinedTables["user_relationship"] = struct{}{} + } + if params.Followee != nil { + query = query.Joins("JOIN user_relationship ON user_relationship.user_id = user.id"). + Where("user_relationship.target_user_id = ?", *params.Followee) + joinedTables["user_relationship"] = struct{}{} + } + switch params.OrderBy { + case ListUsersOrderByCreatedAt: + query = query.Order(fmt.Sprintf("user.created_at %s", params.SortOrder)) + case ListUsersOrderByUpdatedAt: + query = query.Order(fmt.Sprintf("user.updated_at %s", params.SortOrder)) + case ListUsersOrderByFollowedAt: + if _, ok := joinedTables["user_relationship"]; !ok { + query = query.Order(fmt.Sprintf("user.created_at %s", params.SortOrder)) + break + } + query = query.Order(fmt.Sprintf("user_relationship.followed_at %s", params.SortOrder)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count users: %w", err) + } + + var mUsers []model.User + if err := query. + Offset(params.Pagination.Offset()). + Limit(params.Pagination.Size). + Find(&mUsers). + Error; err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + userDTOs := make([]UserDTO, len(mUsers)) + for i, mUser := range mUsers { + userDTOs[i] = toUserDTO(mUser) + } + return &ByPage[UserDTO]{ + Total: total, + Data: userDTOs, + }, nil +} + +// GetUser gets user by username. +func (ctrl *Controller) GetUser(ctx context.Context, username string) (*UserDTO, error) { + var mUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", username). + First(&mUser). + Error; err != nil { + return nil, fmt.Errorf("failed to get user %s: %w", username, err) + } + userDTO := toUserDTO(mUser) + return &userDTO, nil +} + +// UpdateAuthedUserParams holds parameters for updating the authenticated user. +type UpdateAuthedUserParams struct { + Description string +} + +// Validate validates the parameters. +func (p *UpdateAuthedUserParams) Validate() (ok bool, msg string) { + return true, "" +} + +// UpdateAuthedUser updates the authenticated user. +func (ctrl *Controller) UpdateAuthedUser(ctx context.Context, params *UpdateAuthedUserParams) (*UserDTO, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return nil, ErrUnauthorized + } + updates := map[string]any{} + if params.Description != mUser.Description { + updates["description"] = params.Description + } + if len(updates) > 0 { + if err := ctrl.db.WithContext(ctx).Model(mUser).Updates(updates).Error; err != nil { + return nil, fmt.Errorf("failed to update authenticated user %s: %w", mUser.Username, err) + } + } + userDTO := toUserDTO(*mUser) + return &userDTO, nil +} + +// FollowUser follows the target user as the authenticated user. +func (ctrl *Controller) FollowUser(ctx context.Context, targetUsername string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + if mUser.Username == targetUsername { + return ErrBadRequest + } + + var mTargetUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", targetUsername). + First(&mTargetUser). + Error; err != nil { + return fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + mUserRelationship, err := model.FirstOrCreateUserRelationship(ctx, ctrl.db, mUser.ID, mTargetUser.ID) + if err != nil { + return err + } + if mUserRelationship.FollowedAt.Valid { + return nil + } + + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserRelationship). + Update("followed_at", sql.NullTime{ + Time: time.Now().UTC(), + Valid: true, + }); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("following_count", gorm.Expr("following_count + 1")).Error; err != nil { + return err + } + if err := tx.Model(&mTargetUser).Update("follower_count", gorm.Expr("follower_count + 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to follow user %s: %w", targetUsername, err) + } + return nil +} + +// IsFollowingUser checks if the authenticated user is following the target user. +func (ctrl *Controller) IsFollowingUser(ctx context.Context, targetUsername string) (bool, error) { + mUser, ok := UserFromContext(ctx) + if !ok { + return false, ErrUnauthorized + } + if mUser.Username == targetUsername { + return false, ErrBadRequest + } + + var mTargetUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", targetUsername). + First(&mTargetUser). + Error; err != nil { + return false, fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + if err := ctrl.db.WithContext(ctx). + Select("id"). + Where("user_id = ?", mUser.ID). + Where("target_user_id = ?", mTargetUser.ID). + Where("followed_at IS NOT NULL"). + First(&model.UserRelationship{}). + Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, fmt.Errorf("failed to check if user %s is following user %s: %w", mUser.Username, targetUsername, err) + } + return true, nil +} + +// UnfollowUser unfollows the target user as the authenticated user. +func (ctrl *Controller) UnfollowUser(ctx context.Context, targetUsername string) error { + mUser, ok := UserFromContext(ctx) + if !ok { + return ErrUnauthorized + } + if mUser.Username == targetUsername { + return ErrBadRequest + } + + var mTargetUser model.User + if err := ctrl.db.WithContext(ctx). + Where("username = ?", targetUsername). + First(&mTargetUser). + Error; err != nil { + return fmt.Errorf("failed to get target user %s: %w", targetUsername, err) + } + + mUserRelationship, err := model.FirstOrCreateUserRelationship(ctx, ctrl.db, mUser.ID, mTargetUser.ID) + if err != nil { + return err + } + if !mUserRelationship.FollowedAt.Valid { + return nil + } + + if err := ctrl.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if queryResult := tx. + Model(mUserRelationship). + Update("followed_at", sql.NullTime{}); queryResult.Error != nil { + return queryResult.Error + } else if queryResult.RowsAffected == 0 { + return nil + } + if err := tx.Model(mUser).Update("following_count", gorm.Expr("following_count - 1")).Error; err != nil { + return err + } + if err := tx.Model(&mTargetUser).Update("follower_count", gorm.Expr("follower_count - 1")).Error; err != nil { + return err + } + return nil + }); err != nil { + return fmt.Errorf("failed to unfollow user %s: %w", targetUsername, err) + } + return nil } diff --git a/spx-backend/internal/controller/user_test.go b/spx-backend/internal/controller/user_test.go deleted file mode 100644 index d552ec093..000000000 --- a/spx-backend/internal/controller/user_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestUser() *User { - return &User{ - Id: "fake-id", - Name: "fake-name", - Owner: "fake-owner", - } -} - -func newContextWithTestUser(ctx context.Context) context.Context { - return NewContextWithUser(ctx, newTestUser()) -} - -func TestNewContextWithUser(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctx := NewContextWithUser(context.Background(), newTestUser()) - user, ok := ctx.Value(userContextKey).(*User) - require.True(t, ok) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) - }) -} - -func TestUserFromContext(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctx := newContextWithTestUser(context.Background()) - user, ok := UserFromContext(ctx) - require.True(t, ok) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) - }) - - t.Run("NoUser", func(t *testing.T) { - user, ok := UserFromContext(context.Background()) - require.False(t, ok) - require.Nil(t, user) - }) -} - -func TestEnsureUser(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctx := newContextWithTestUser(context.Background()) - user, err := EnsureUser(ctx, "fake-name") - require.NoError(t, err) - require.NotNil(t, user) - }) - - t.Run("NoUser", func(t *testing.T) { - _, err := EnsureUser(context.Background(), "fake-name") - require.Error(t, err) - assert.ErrorIs(t, err, ErrUnauthorized) - }) - - t.Run("UnexpectedUser", func(t *testing.T) { - ctx := newContextWithTestUser(context.Background()) - _, err := EnsureUser(ctx, "unexpected-name") - require.Error(t, err) - assert.ErrorIs(t, err, ErrForbidden) - }) -} - -const fakeUserToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + - "eyJvd25lciI6IkdvUGx1cyIsIm5hbWUiOiJmYWtlLW5hbWUiLCJpZCI6IjEiLCJpc3MiOiJHb1BsdXMiLCJzdWIiOiIxIiwiZXhwIjo0ODcwNDI5MDQwfQ." + - "X0T-v-RJggMRy3Mmui2FoRH-_4DQsNA6DekUx1BfIljTZaEbHbuW59dSlKQ-i2MuYD7_8mI18vZqT3iysbKQ1T70NF97B_A130ML3pulZWlj1ZokgjCkVug25QRbq_N7JMd4apJZFlyZj8Bd2VfqtAKMlJJ4HzKzNXB-GBogDVlKeu4xJ1BiXO2rHL1PNa5KyKLSSMXmuP_Wc108RXZ0BiKDE30IG1fvcyvudXcetmltuWjuU6JRj3FGedxuVEqZLXqcm13dCxHnuFV1x1XU9KExcDvVyVB91FpBe5npzYp6WMX0fx9vU1b4eJ69EZoeMdMolhmvYInT1G8r1PEmbg" - -func TestControllerUserFromToken(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - user, err := ctrl.UserFromToken(fakeUserToken) - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, "fake-name", user.Name) - }) - - t.Run("InvalidToken", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.UserFromToken("invalid-token") - require.Error(t, err) - assert.EqualError(t, err, "ctrl.casdoorClient.ParseJwtToken failed: token contains an invalid number of segments") - }) -} diff --git a/spx-backend/internal/controller/util_test.go b/spx-backend/internal/controller/util_test.go deleted file mode 100644 index 14ef811ea..000000000 --- a/spx-backend/internal/controller/util_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFmtCodeParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &FmtCodeParams{ - Body: "package main\n\nfunc main() {}\n", - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) - - t.Run("EmptyBody", func(t *testing.T) { - params := &FmtCodeParams{} - ok, msg := params.Validate() - assert.False(t, ok) - assert.Equal(t, "missing body", msg) - }) -} - -func TestControllerFmtCode(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - formattedCode, err := ctrl.FmtCode(context.Background(), &FmtCodeParams{ - Body: "package main\n\nfunc main() {}\n", - }) - require.NoError(t, err) - require.NotNil(t, formattedCode) - require.NotEmpty(t, formattedCode.Body) - require.Nil(t, formattedCode.Error) - assert.Equal(t, "\n", formattedCode.Body) - }) - - t.Run("FormatError", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - formattedCode, err := ctrl.FmtCode(context.Background(), &FmtCodeParams{ - Body: "package main\n\nfunc main() {", - }) - require.NoError(t, err) - require.NotNil(t, formattedCode) - require.Empty(t, formattedCode.Body) - require.NotNil(t, formattedCode.Error) - assert.Equal(t, 3, formattedCode.Error.Line) - assert.Equal(t, 15, formattedCode.Error.Column) - assert.Equal(t, "expected '}', found 'EOF'", formattedCode.Error.Msg) - }) - - t.Run("InvalidBody", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.FmtCode(context.Background(), &FmtCodeParams{ - Body: "-- prog.go --\n-- prog.go --", - }) - require.Error(t, err) - assert.EqualError(t, err, `duplicate file name "prog.go"`) - }) -} - -func TestControllerGetUpInfo(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - upInfo, err := ctrl.GetUpInfo(context.Background()) - require.NoError(t, err) - require.NotNil(t, upInfo) - assert.NotEmpty(t, upInfo.Token) - assert.NotZero(t, upInfo.Expires) - assert.NotZero(t, upInfo.MaxSize) - assert.Equal(t, ctrl.kodo.bucket, upInfo.Bucket) - assert.Equal(t, ctrl.kodo.bucketRegion, upInfo.Region) - }) -} - -func TestMakeFileURLsParamsValidate(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - params := &MakeFileURLsParams{ - Objects: []string{"kodo://builder/foo/bar"}, - } - ok, msg := params.Validate() - assert.True(t, ok) - assert.Empty(t, msg) - }) -} - -func TestControllerMakeFileURLs(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - fileURLs, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"kodo://builder/foo/bar"}, - }) - require.NoError(t, err) - require.NotNil(t, fileURLs) - assert.Len(t, fileURLs.ObjectURLs, 1) - }) - - t.Run("EmptyObjects", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - fileURLs, err := ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{}) - require.NoError(t, err) - require.NotNil(t, fileURLs) - assert.Empty(t, fileURLs.ObjectURLs) - }) - - t.Run("InvalidObject", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"://invalid"}, - }) - require.Error(t, err) - assert.EqualError(t, err, `parse "://invalid": missing protocol scheme`) - }) - - t.Run("UnrecognizedObject", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"not-kodo://builder/foo/bar"}, - }) - require.Error(t, err) - assert.EqualError(t, err, "unrecognized object: not-kodo://builder/foo/bar") - }) - - t.Run("URLJoinPathError", func(t *testing.T) { - ctrl, _, err := newTestController(t) - require.NoError(t, err) - ctrl.kodo.baseUrl = "://invalid" - - _, err = ctrl.MakeFileURLs(context.Background(), &MakeFileURLsParams{ - Objects: []string{"kodo://builder/foo/bar"}, - }) - require.Error(t, err) - assert.EqualError(t, err, `parse "://invalid": missing protocol scheme`) - }) -} diff --git a/spx-backend/internal/model/asset.go b/spx-backend/internal/model/asset.go index b592fbfcf..55028fbef 100644 --- a/spx-backend/internal/model/asset.go +++ b/spx-backend/internal/model/asset.go @@ -1,61 +1,40 @@ package model -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/goplus/builder/spx-backend/internal/log" -) - -// Asset is the model for an asset. +// Asset is the model for assets. type Asset struct { - // ID is the globally unique identifier. - ID string `db:"id" json:"id"` - - // CTime is the creation time. - CTime time.Time `db:"c_time" json:"cTime"` - - // UTime is the last update time. - UTime time.Time `db:"u_time" json:"uTime"` + Model - // DisplayName is the name to display. - DisplayName string `db:"display_name" json:"displayName"` + // OwnerID is the ID of the asset owner. + OwnerID int64 `gorm:"column:owner_id;index"` + Owner User `gorm:"foreignKey:OwnerID"` - // Owner is the name of the asset owner. - Owner string `db:"owner" json:"owner"` + // DisplayName is the display name. + DisplayName string `gorm:"column:display_name;index:,class:FULLTEXT"` - // Category is the asset category. - Category string `db:"category" json:"category"` + // Type is the type of the asset. + Type AssetType `gorm:"column:type;index"` - // AssetType indicates the type of the asset. - AssetType AssetType `db:"asset_type" json:"assetType"` + // Category is the category to which the asset belongs. + Category string `gorm:"column:category;index"` - // Files contains the asset's files. - Files FileCollection `db:"files" json:"files"` + // Files contains the file paths and their corresponding universal URLs + // associated with the asset. + Files FileCollection `gorm:"column:files"` - // FilesHash is the hash of the asset's files. - FilesHash string `db:"files_hash" json:"filesHash"` + // FilesHash is the hash of the asset files. + FilesHash string `gorm:"column:files_hash"` - // Preview is the URL for the asset preview, e.g., a gif for a sprite. - Preview string `db:"preview" json:"preview"` - - // ClickCount is the number of clicks on the asset. - ClickCount int64 `db:"click_count" json:"clickCount"` - - // IsPublic indicates if the asset is public. - IsPublic IsPublic `db:"is_public" json:"isPublic"` - - // Status indicates if the asset is deleted. - Status Status `db:"status" json:"status"` + // Visibility is the visibility. + Visibility Visibility `gorm:"column:visibility;index"` } -// TableAsset is the table name of [Asset] in database. -const TableAsset = "asset" +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (Asset) TableName() string { + return "asset" +} // AssetType is the type of asset. -type AssetType int +type AssetType uint8 const ( AssetTypeSprite AssetType = iota @@ -63,53 +42,29 @@ const ( AssetTypeSound ) -// AssetByID gets asset with given id. Returns `ErrNotExist` if it does not exist. -func AssetByID(ctx context.Context, db *sql.DB, id string) (*Asset, error) { - return QueryByID[Asset](ctx, db, TableAsset, id) -} - -// ListAssets lists assets with given pagination, where conditions and order by conditions. -func ListAssets(ctx context.Context, db *sql.DB, paginaton Pagination, filters []FilterCondition, orderBy []OrderByCondition) (*ByPage[Asset], error) { - return QueryByPage[Asset](ctx, db, TableAsset, paginaton, filters, orderBy) -} - -// AddAsset adds an asset. -func AddAsset(ctx context.Context, db *sql.DB, a *Asset) (*Asset, error) { - return Create(ctx, db, TableAsset, a) -} - -// UpdateAssetByID updates asset with given id. -func UpdateAssetByID(ctx context.Context, db *sql.DB, id string, a *Asset) (*Asset, error) { - logger := log.GetReqLogger(ctx) - if err := UpdateByID(ctx, db, TableAsset, id, a, "display_name", "category", "asset_type", "files", "files_hash", "preview", "is_public"); err != nil { - logger.Printf("UpdateByID failed: %v", err) - return nil, err +// ParseAssetType parses the string representation of the asset type. +func ParseAssetType(s string) AssetType { + switch s { + case "sprite": + return AssetTypeSprite + case "backdrop": + return AssetTypeBackdrop + case "sound": + return AssetTypeSound } - return AssetByID(ctx, db, id) + return 255 } -// IncreaseAssetClickCount increases asset's click count by 1. -func IncreaseAssetClickCount(ctx context.Context, db *sql.DB, id string) error { - logger := log.GetReqLogger(ctx) - - query := fmt.Sprintf("UPDATE %s SET u_time = ?, click_count = click_count + 1 WHERE id = ?", TableAsset) - result, err := db.ExecContext(ctx, query, time.Now().UTC(), id) - if err != nil { - logger.Printf("db.ExecContext failed: %v", err) - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - logger.Printf("result.RowsAffected failed: %v", err) - return err - } else if rowsAffected == 0 { - return ErrNotExist +// String implements [fmt.Stringer]. It returns the string representation of the +// asset type. +func (at AssetType) String() string { + switch at { + case AssetTypeSprite: + return "sprite" + case AssetTypeBackdrop: + return "backdrop" + case AssetTypeSound: + return "sound" } - return nil -} - -// DeleteAssetByID deletes asset with given id. -func DeleteAssetByID(ctx context.Context, db *sql.DB, id string) error { - return UpdateByID(ctx, db, TableAsset, id, &Asset{Status: StatusDeleted}, "status") + return "unknown" } diff --git a/spx-backend/internal/model/asset_test.go b/spx-backend/internal/model/asset_test.go deleted file mode 100644 index 5ba5d33eb..000000000 --- a/spx-backend/internal/model/asset_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAssetByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - asset, err := AssetByID(context.Background(), db, "1") - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "foo", asset.DisplayName) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - asset, err := AssetByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, asset) - }) -} - -func TestListAssets(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - assets, err := ListAssets(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.NoError(t, err) - require.NotNil(t, assets) - assert.Equal(t, 1, assets.Total) - assert.Len(t, assets.Data, 1) - assert.Equal(t, "foo", assets.Data[0].DisplayName) - }) - - t.Run("ClosedConnForCountQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM asset WHERE status != \?`). - WillReturnError(sql.ErrConnDone) - assets, err := ListAssets(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, assets) - }) -} - -func TestAddAsset(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO asset \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - asset, err := AddAsset(context.Background(), db, &Asset{DisplayName: "foo"}) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "foo", asset.DisplayName) - }) - - t.Run("ClosedConnForInsertQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO asset \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnError(sql.ErrConnDone) - asset, err := AddAsset(context.Background(), db, &Asset{DisplayName: "foo"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, asset) - }) -} - -func TestUpdateAssetByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM asset WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"display_name"}). - AddRow("foo")) - asset, err := UpdateAssetByID(context.Background(), db, "1", &Asset{DisplayName: "foo"}) - require.NoError(t, err) - require.NotNil(t, asset) - assert.Equal(t, "foo", asset.DisplayName) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,display_name=\?,category=\?,asset_type=\?,files=\?,files_hash=\?,preview=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - asset, err := UpdateAssetByID(context.Background(), db, "1", &Asset{DisplayName: "foo"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, asset) - }) -} - -func TestIncreaseAssetClickCount(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.NoError(t, err) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 0)) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) - - t.Run("ClosedConnForRowsAffected", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time = \?, click_count = click_count \+ 1 WHERE id = \?`). - WithArgs(sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewErrorResult(sql.ErrConnDone)) - err = IncreaseAssetClickCount(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestDeleteAssetByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = DeleteAssetByID(context.Background(), db, "1") - require.NoError(t, err) - }) - - t.Run("ClosedConnForDeleteQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE asset SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = DeleteAssetByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/model/clause.go b/spx-backend/internal/model/clause.go deleted file mode 100644 index 008ec335d..000000000 --- a/spx-backend/internal/model/clause.go +++ /dev/null @@ -1,65 +0,0 @@ -package model - -import ( - "fmt" - "strings" -) - -// FilterCondition represents a condition to filter rows. -type FilterCondition struct { - Column string // column name - Operation string // "=", "<", "!=" ... - Value any // value -} - -// Expr returns the expression of the condition for use in a parameterized query. -func (cond *FilterCondition) Expr() string { - return fmt.Sprintf("%s %s ?", cond.Column, cond.Operation) -} - -// buildWhereClause builds a WHERE clause from the given conditions. -// -// The deleted items are filtered out by default. -func buildWhereClause(conds []FilterCondition) (string, []any) { - var ( - exprs = make([]string, 0, len(conds)+1) - args = make([]any, 0, len(conds)+1) - ) - for _, cond := range conds { - exprs = append(exprs, cond.Expr()) - args = append(args, cond.Value) - } - - // Filter out deleted items. - exprs = append(exprs, "status != ?") - args = append(args, StatusDeleted) - - whereClause := "WHERE " + strings.Join(exprs, " AND ") - return whereClause, args -} - -// OrderByCondition represents a condition to order rows. -type OrderByCondition struct { - Column string // column name - Direction string // ASC or DESC -} - -// Expr returns the expression of the condition for use in a parameterized query. -func (cond *OrderByCondition) Expr() string { - return fmt.Sprintf("%s %s", cond.Column, cond.Direction) -} - -// buildOrderByClause builds an ORDER BY clause from the given conditions. -// -// If no conditions are given, the default order is by ID in ascending order. -func buildOrderByClause(conds []OrderByCondition) string { - if len(conds) == 0 { - return "ORDER BY id ASC" - } - exprs := make([]string, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.Expr()) - } - orderByClause := "ORDER BY " + strings.Join(exprs, ", ") - return orderByClause -} diff --git a/spx-backend/internal/model/clause_test.go b/spx-backend/internal/model/clause_test.go deleted file mode 100644 index a44c0d049..000000000 --- a/spx-backend/internal/model/clause_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package model - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFilterConditionExpr(t *testing.T) { - t.Run("Equal", func(t *testing.T) { - cond := FilterCondition{"a", "=", 1} - assert.Equal(t, "a = ?", cond.Expr()) - }) - - t.Run("Empty", func(t *testing.T) { - cond := FilterCondition{} - assert.Equal(t, " ?", cond.Expr()) - }) -} - -func TestBuildWhereClause(t *testing.T) { - t.Run("Nil", func(t *testing.T) { - clause, args := buildWhereClause(nil) - assert.Equal(t, "WHERE status != ?", clause) - assert.Equal(t, []any{StatusDeleted}, args) - }) - - t.Run("OneCondition", func(t *testing.T) { - clause, args := buildWhereClause([]FilterCondition{ - {"a", "=", 1}, - }) - assert.Equal(t, "WHERE a = ? AND status != ?", clause) - assert.Equal(t, []any{1, StatusDeleted}, args) - }) - - t.Run("MultipleConditions", func(t *testing.T) { - clause, args := buildWhereClause([]FilterCondition{ - {"a", "=", 1}, - {"b", "!=", 2}, - }) - assert.Equal(t, "WHERE a = ? AND b != ? AND status != ?", clause) - assert.Equal(t, []any{1, 2, StatusDeleted}, args) - }) -} - -func TestOrderByConditionExpr(t *testing.T) { - t.Run("ASC", func(t *testing.T) { - cond := OrderByCondition{"a", "ASC"} - assert.Equal(t, "a ASC", cond.Expr()) - }) - - t.Run("Empty", func(t *testing.T) { - cond := OrderByCondition{} - assert.Equal(t, " ", cond.Expr()) - }) -} - -func TestBuildOrderByClause(t *testing.T) { - t.Run("Nil", func(t *testing.T) { - clause := buildOrderByClause(nil) - assert.Equal(t, "ORDER BY id ASC", clause) - }) - - t.Run("OneCondition", func(t *testing.T) { - clause := buildOrderByClause([]OrderByCondition{ - {"a", "ASC"}, - }) - assert.Equal(t, "ORDER BY a ASC", clause) - }) - - t.Run("MultipleConditions", func(t *testing.T) { - clause := buildOrderByClause([]OrderByCondition{ - {"a", "ASC"}, - {"b", "DESC"}, - }) - assert.Equal(t, "ORDER BY a ASC, b DESC", clause) - }) -} diff --git a/spx-backend/internal/model/file.go b/spx-backend/internal/model/file.go deleted file mode 100644 index 667e1c746..000000000 --- a/spx-backend/internal/model/file.go +++ /dev/null @@ -1,34 +0,0 @@ -package model - -import ( - "database/sql/driver" - "encoding/json" - "errors" - "fmt" -) - -// FileCollection is a map from relative path to universal URL. It is used to -// store files information for [Projetc] and [Asset]. -type FileCollection map[string]string - -// Scan implements [sql.Scanner]. -func (fc *FileCollection) Scan(src any) error { - switch src := src.(type) { - case []byte: - var parsed FileCollection - if err := json.Unmarshal(src, &parsed); err != nil { - return fmt.Errorf("failed to unmarshal FileCollection: %w", err) - } - *fc = parsed - case nil: - *fc = FileCollection{} - default: - return errors.New("incompatible type for FileCollection") - } - return nil -} - -// Value implements [driver.Valuer]. -func (fc FileCollection) Value() (driver.Value, error) { - return json.Marshal(fc) -} diff --git a/spx-backend/internal/model/model.go b/spx-backend/internal/model/model.go index a87ca6d64..255abb396 100644 --- a/spx-backend/internal/model/model.go +++ b/spx-backend/internal/model/model.go @@ -1,24 +1,103 @@ package model -import "errors" +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "time" -var ( - ErrExist = errors.New("item already existed") - ErrNotExist = errors.New("item does not exist") + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) -// IsPublic indicates the visibility of an item. -type IsPublic int +// Model is the base model for all models. +type Model struct { + ID int64 `gorm:"column:id;autoIncrement;primaryKey"` + CreatedAt time.Time `gorm:"column:created_at;not null"` + UpdatedAt time.Time `gorm:"column:updated_at;not null"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` +} -const ( - Personal IsPublic = iota - Public -) +// OpenDB opens the database with the given dsn and models to be migrated. +func OpenDB(ctx context.Context, dsn string, maxOpenConns, maxIdleConns int, models ...any) (*gorm.DB, error) { + dialector := mysql.New(mysql.Config{DSN: dsn}) + db, err := gorm.Open(dialector, &gorm.Config{ + Logger: logger.Discard, + NowFunc: func() time.Time { return time.Now().UTC() }, + }) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get database/sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(maxOpenConns) + sqlDB.SetMaxIdleConns(maxIdleConns) + if len(models) > 0 { + if err := db.WithContext(ctx).AutoMigrate(models...); err != nil { + return nil, fmt.Errorf("failed to auto migrate models: %w", err) + } + } + return db, nil +} -// Status indicates the status of an item. -type Status int +// Visibility indicates the visibility of an item. +type Visibility uint8 const ( - StatusDeleted Status = iota - StatusNormal + VisibilityPrivate Visibility = iota + VisibilityPublic ) + +// ParseVisibility parses the string representation of the visibility. +func ParseVisibility(s string) Visibility { + switch s { + case "private": + return VisibilityPrivate + case "public": + return VisibilityPublic + } + return 255 +} + +// String implements [fmt.Stringer]. It returns the string representation of the +// visibility. +func (v Visibility) String() string { + switch v { + case VisibilityPrivate: + return "private" + case VisibilityPublic: + return "public" + } + return "unknown" +} + +// FileCollection is a map from relative path to universal URL. It is used to +// store files information. +type FileCollection map[string]string + +// Scan implements [sql.Scanner]. +func (fc *FileCollection) Scan(src any) error { + switch src := src.(type) { + case []byte: + var parsed FileCollection + if err := json.Unmarshal(src, &parsed); err != nil { + return fmt.Errorf("failed to unmarshal FileCollection: %w", err) + } + *fc = parsed + case nil: + *fc = FileCollection{} + default: + return errors.New("incompatible type for FileCollection") + } + return nil +} + +// Value implements [driver.Valuer]. +func (fc FileCollection) Value() (driver.Value, error) { + return json.Marshal(fc) +} diff --git a/spx-backend/internal/model/file_test.go b/spx-backend/internal/model/model_test.go similarity index 100% rename from spx-backend/internal/model/file_test.go rename to spx-backend/internal/model/model_test.go diff --git a/spx-backend/internal/model/project.go b/spx-backend/internal/model/project.go index 2c9604365..d14060168 100644 --- a/spx-backend/internal/model/project.go +++ b/spx-backend/internal/model/project.go @@ -1,91 +1,59 @@ package model -import ( - "context" - "database/sql" - "errors" - "time" +import "database/sql" - "github.com/goplus/builder/spx-backend/internal/log" -) - -// Project is the model for a project. +// Project is the model for projects. type Project struct { - // ID is the globally unique identifier. - ID string `db:"id" json:"id"` + Model - // CTime is the creation time. - CTime time.Time `db:"c_time" json:"cTime"` + // OwnerID is the ID of the project owner. + OwnerID int64 `gorm:"column:owner_id;index;index:,composite:owner_id_name,unique,where:deleted_at IS NULL"` + Owner User `gorm:"foreignKey:OwnerID"` - // UTime is the last update time. - UTime time.Time `db:"u_time" json:"uTime"` + // RemixedFromReleaseID is the ID of the project release from which the + // project is remixed. + // + // If RemixedFromReleaseID.Valid is false, it means the project is not a + // remix. + RemixedFromReleaseID sql.NullInt64 `gorm:"column:remixed_from_release_id;index"` + RemixedFromRelease *ProjectRelease `gorm:"foreignKey:RemixedFromReleaseID"` - // Name is the project's name, unique for projects of the same owner. - Name string `db:"name" json:"name"` + // Name is the unique name. + Name string `gorm:"column:name;index:,class:FULLTEXT;index:,composite:owner_id_name,unique,where:deleted_at IS NULL"` - // Owner is the name of the project owner. - Owner string `db:"owner" json:"owner"` + // Version is the version number. + Version int `gorm:"column:version"` - // Version is the project version. - Version int `db:"version" json:"version"` + // Files contains the file paths and their corresponding universal URLs + // associated with the project. + Files FileCollection `gorm:"column:files"` - // Files contains the project's files. - Files FileCollection `db:"files" json:"files"` + // Visibility is the visibility. + Visibility Visibility `gorm:"column:visibility;index"` - // IsPublic indicates if the project is public. - IsPublic IsPublic `db:"is_public" json:"isPublic"` + // Description is the brief description. + Description string `gorm:"column:description"` - // Status indicates if the project is deleted. - Status Status `db:"status" json:"status"` -} + // Instructions is the instructions on how to interact with the project. + Instructions string `gorm:"column:instructions"` -// TableProject is the table name of [Project] in database. -const TableProject = "project" + // Thumbnail is the URL of the thumbnail image. + Thumbnail string `gorm:"column:thumbnail"` -// ProjectByID gets project with given id. Returns `ErrNotExist` if it does not exist. -func ProjectByID(ctx context.Context, db *sql.DB, id string) (*Project, error) { - return QueryByID[Project](ctx, db, TableProject, id) -} + // ViewCount is the number of times the project has been viewed. + ViewCount int64 `gorm:"column:view_count;index"` -// ProjectByOwnerAndName gets project with given owner and name. Returns `ErrNotExist` if it does not exist -func ProjectByOwnerAndName(ctx context.Context, db *sql.DB, owner string, name string) (*Project, error) { - where := []FilterCondition{ - {Column: "owner", Operation: "=", Value: owner}, - {Column: "name", Operation: "=", Value: name}, - } - return QueryFirst[Project](ctx, db, TableProject, where, nil) -} + // LikeCount is the number of times the project has been liked. + LikeCount int64 `gorm:"column:like_count;index"` -// ListProjects lists projects with given pagination, where conditions and order by conditions. -func ListProjects(ctx context.Context, db *sql.DB, paginaton Pagination, where []FilterCondition, orderBy []OrderByCondition) (*ByPage[Project], error) { - return QueryByPage[Project](ctx, db, TableProject, paginaton, where, orderBy) -} - -// AddProject adds a project. -func AddProject(ctx context.Context, db *sql.DB, p *Project) (*Project, error) { - logger := log.GetReqLogger(ctx) - - if _, err := ProjectByOwnerAndName(ctx, db, p.Owner, p.Name); err == nil { - return nil, ErrExist - } else if !errors.Is(err, ErrNotExist) { - logger.Printf("ProjectByOwnerAndName failed: %v", err) - return nil, err - } - - return Create(ctx, db, TableProject, p) -} + // ReleaseCount is the number of releases associated with the project. + ReleaseCount int64 `gorm:"column:release_count;index"` -// UpdateProjectByID updates project with given id. -func UpdateProjectByID(ctx context.Context, db *sql.DB, id string, p *Project) (*Project, error) { - logger := log.GetReqLogger(ctx) - if err := UpdateByID(ctx, db, TableProject, id, p, "version", "files", "is_public"); err != nil { - logger.Printf("UpdateByID failed: %v", err) - return nil, err - } - return ProjectByID(ctx, db, id) + // RemixCount is the number of times the project has been remixed. + RemixCount int64 `gorm:"column:remix_count;index"` } -// DeleteProjectByID deletes project with given id. -func DeleteProjectByID(ctx context.Context, db *sql.DB, id string) error { - return UpdateByID(ctx, db, TableProject, id, &Project{Status: StatusDeleted}, "status") +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (Project) TableName() string { + return "project" } diff --git a/spx-backend/internal/model/project_release.go b/spx-backend/internal/model/project_release.go new file mode 100644 index 000000000..1eab44fc3 --- /dev/null +++ b/spx-backend/internal/model/project_release.go @@ -0,0 +1,31 @@ +package model + +// ProjectRelease is the model for project releases. +type ProjectRelease struct { + Model + + // ProjectID is the ID of the project that the release is associated with. + ProjectID int64 `gorm:"column:project_id;index;index:,composite:project_id_name,unique"` + Project Project `gorm:"foreignKey:ProjectID"` + + // Name is the unique name of the project release. + Name string `gorm:"column:name;index:,class:FULLTEXT;index:,composite:project_id_name,unique"` + + // Description is the brief description. + Description string `gorm:"column:description"` + + // Files contains file paths and their corresponding universal URLs + // associated with the release. + Files FileCollection `gorm:"column:files"` + + // Thumbnail is the URL of the thumbnail image. + Thumbnail string `gorm:"column:thumbnail"` + + // RemixCount is the number of times the release has been remixed. + RemixCount int64 `gorm:"column:remix_count;index"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (ProjectRelease) TableName() string { + return "project_release" +} diff --git a/spx-backend/internal/model/project_test.go b/spx-backend/internal/model/project_test.go deleted file mode 100644 index 6008ea222..000000000 --- a/spx-backend/internal/model/project_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestProjectByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := ProjectByID(context.Background(), db, "1") - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - project, err := ProjectByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, project) - }) -} - -func TestProjectByOwnerAndName(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := ProjectByOwnerAndName(context.Background(), db, "owner", "name") - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - project, err := ProjectByOwnerAndName(context.Background(), db, "owner", "name") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, project) - }) -} - -func TestListProjects(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"COUNT(*)"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - projects, err := ListProjects(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.NoError(t, err) - require.NotNil(t, projects) - assert.Equal(t, 1, projects.Total) - assert.Len(t, projects.Data, 1) - assert.Equal(t, "foo", projects.Data[0].Name) - }) - - t.Run("ClosedConnForCountQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM project WHERE status != \?`). - WillReturnError(sql.ErrConnDone) - projects, err := ListProjects(context.Background(), db, Pagination{Index: 1, Size: 1}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, projects) - }) -} - -func TestAddProject(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - mock.ExpectExec(`INSERT INTO project \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("Exist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.Error(t, err) - assert.ErrorIs(t, err, ErrExist) - assert.Nil(t, project) - }) - - t.Run("ClosedConnForProjectByOwnerAndName", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnError(sql.ErrConnDone) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, project) - }) - - t.Run("ClosedConnForInsertQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM project WHERE owner = \? AND name = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - mock.ExpectExec(`INSERT INTO project \(.+\) VALUES \(\?,\?,\?,\?,\?,\?,\?,\?\)`). - WillReturnError(sql.ErrConnDone) - project, err := AddProject(context.Background(), db, &Project{Name: "foo", Owner: "owner"}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, project) - }) -} - -func TestUpdateProjectByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), 2, sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM project WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"name"}). - AddRow("foo")) - project, err := UpdateProjectByID(context.Background(), db, "1", &Project{Version: 2}) - require.NoError(t, err) - require.NotNil(t, project) - assert.Equal(t, "foo", project.Name) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,version=\?,files=\?,is_public=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), 2, sqlmock.AnyArg(), sqlmock.AnyArg(), "1"). - WillReturnError(sql.ErrConnDone) - project, err := UpdateProjectByID(context.Background(), db, "1", &Project{Version: 2}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, project) - }) -} - -func TestDeleteProjectByID(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnResult(sqlmock.NewResult(1, 1)) - err = DeleteProjectByID(context.Background(), db, "1") - require.NoError(t, err) - }) - - t.Run("ClosedConnForDeleteQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE project SET u_time=\?,status=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), StatusDeleted, "1"). - WillReturnError(sql.ErrConnDone) - err = DeleteProjectByID(context.Background(), db, "1") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/model/query.go b/spx-backend/internal/model/query.go deleted file mode 100644 index 7880f7606..000000000 --- a/spx-backend/internal/model/query.go +++ /dev/null @@ -1,220 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/goplus/builder/spx-backend/internal/log" -) - -// Query queries a table. -func Query[T any](ctx context.Context, db *sql.DB, table string, where []FilterCondition, orderBy []OrderByCondition) ([]T, error) { - logger := log.GetReqLogger(ctx) - - whereClause, whereArgs := buildWhereClause(where) - orderByClause := buildOrderByClause(orderBy) - - query := fmt.Sprintf("SELECT * FROM %s %s %s", table, whereClause, orderByClause) - rows, err := db.QueryContext(ctx, query, whereArgs...) - if err != nil { - logger.Printf("db.QueryContext failed: %v", err) - return nil, err - } - defer rows.Close() - - var items []T - for rows.Next() { - item, err := rowsScan[T](rows) - if err != nil { - logger.Printf("rowsScan failed: %v", err) - return nil, err - } - items = append(items, item) - } - return items, nil -} - -// Pagination is the pagination information. -type Pagination struct { - Index int - Size int -} - -// ByPage is a generic struct for paginated data. -type ByPage[T any] struct { - Total int `json:"total"` - Data []T `json:"data"` -} - -// QueryByPage queries a table by page. -func QueryByPage[T any](ctx context.Context, db *sql.DB, table string, paginaton Pagination, where []FilterCondition, orderBy []OrderByCondition) (*ByPage[T], error) { - logger := log.GetReqLogger(ctx) - - whereClause, whereArgs := buildWhereClause(where) - orderByClause := buildOrderByClause(orderBy) - - var total int - countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s %s", table, whereClause) - if err := db.QueryRowContext(ctx, countQuery, whereArgs...).Scan(&total); err != nil { - logger.Printf("db.QueryRowContext failed: %v", err) - return nil, err - } - - offset := (paginaton.Index - 1) * paginaton.Size - query := fmt.Sprintf("SELECT * FROM %s %s %s LIMIT ?, ?", table, whereClause, orderByClause) - args := append(whereArgs, offset, paginaton.Size) - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - logger.Printf("db.QueryContext failed: %v", err) - return nil, err - } - defer rows.Close() - - data := make([]T, 0, paginaton.Size) - for rows.Next() { - item, err := rowsScan[T](rows) - if err != nil { - logger.Printf("rowsScan failed: %v", err) - return nil, err - } - data = append(data, item) - } - - return &ByPage[T]{ - Total: total, - Data: data, - }, nil -} - -// QueryFirst queries a table and returns the first result. Returns [ErrNotExist] if it does not exist. -func QueryFirst[T any](ctx context.Context, db *sql.DB, table string, where []FilterCondition, orderBy []OrderByCondition) (*T, error) { - logger := log.GetReqLogger(ctx) - - whereClause, whereArgs := buildWhereClause(where) - orderByClause := buildOrderByClause(orderBy) - - query := fmt.Sprintf("SELECT * FROM %s %s %s LIMIT 1", table, whereClause, orderByClause) - rows, err := db.QueryContext(ctx, query, whereArgs...) - if err != nil { - logger.Printf("db.QueryContext failed: %v", err) - return nil, err - } - defer rows.Close() - - if !rows.Next() { - return nil, ErrNotExist - } - item, err := rowsScan[T](rows) - if err != nil { - logger.Printf("rowsScan failed: %v", err) - return nil, err - } - return &item, nil -} - -// QueryByID queries an item by ID. Returns [ErrNotExist] if it does not exist. -func QueryByID[T any](ctx context.Context, db *sql.DB, table string, id string) (*T, error) { - where := []FilterCondition{{Column: "id", Operation: "=", Value: id}} - return QueryFirst[T](ctx, db, table, where, nil) -} - -// Create creates an item. -func Create[T any](ctx context.Context, db *sql.DB, table string, item *T) (*T, error) { - logger := log.GetReqLogger(ctx) - - itemValue, dbFields, err := reflectModelItem(item) - if err != nil { - logger.Printf("failed to reflect model item: %v", err) - return nil, err - } - if len(dbFields) == 0 { - return nil, errors.New("no db fields found") - } - - now := time.Now().UTC() - columns := make([]string, 0, len(dbFields)) - values := make([]any, 0, len(dbFields)) - for dbTag, dbField := range dbFields { - var value any - switch dbTag { - case "id": - // Skip id column since it's supposed to be auto-generated. - continue - case "c_time", "u_time": - value = now - case "status": - value = StatusNormal - default: - value = itemValue.FieldByIndex(dbField.Index).Interface() - } - columns = append(columns, dbTag) - values = append(values, value) - } - - joinedColumns := strings.Join(columns, ",") - joinedPlaceholders := strings.Repeat(",?", len(columns))[1:] - query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, joinedColumns, joinedPlaceholders) - - result, err := db.ExecContext(ctx, query, values...) - if err != nil { - logger.Printf("db.ExecContext failed: %v", err) - return nil, err - } - - id, err := result.LastInsertId() - if err != nil { - logger.Printf("failed to get last insert id: %v", err) - return nil, err - } - return QueryByID[T](ctx, db, table, strconv.FormatInt(id, 10)) -} - -// UpdateByID updates an item by ID. -func UpdateByID[T any](ctx context.Context, db *sql.DB, table string, id string, item *T, columns ...string) error { - logger := log.GetReqLogger(ctx) - - itemValue, dbFields, err := reflectModelItem(item) - if err != nil { - logger.Printf("failed to reflect model item: %v", err) - return err - } - - exprs := make([]string, 1, len(columns)+1) - exprs[0] = "u_time=?" - args := make([]any, 1, len(columns)+1) - args[0] = time.Now().UTC() - for _, col := range columns { - dbField, ok := dbFields[col] - if !ok { - return fmt.Errorf("column %s does not exist in struct", col) - } - switch col { - case "id", "c_time", "u_time": - return fmt.Errorf("column %s is read-only", col) - } - exprs = append(exprs, fmt.Sprintf("%s=?", col)) - args = append(args, itemValue.FieldByIndex(dbField.Index).Interface()) - } - args = append(args, id) - - query := fmt.Sprintf("UPDATE %s SET %s WHERE id=?", table, strings.Join(exprs, ",")) - result, err := db.ExecContext(ctx, query, args...) - if err != nil { - logger.Printf("db.ExecContext failed: %v", err) - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - logger.Printf("result.RowsAffected failed: %v", err) - return err - } else if rowsAffected == 0 { - return ErrNotExist - } - return nil -} diff --git a/spx-backend/internal/model/query_test.go b/spx-backend/internal/model/query_test.go deleted file mode 100644 index 63cd168c7..000000000 --- a/spx-backend/internal/model/query_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package model - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQuery(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - users, err := Query[User](context.Background(), db, "user", nil, nil) - require.NoError(t, err) - require.Len(t, users, 1) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, users[0]) - }) - - t.Run("ClosedConn", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC`). - WillReturnError(sql.ErrConnDone) - users, err := Query[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, users) - }) - - t.Run("ScanError", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC`). - WillReturnRows(mock.NewRows([]string{"id", "name", "age", "status"}). - AddRow(1, "foo", 18, StatusNormal)) - users, err := Query[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "column age does not exist in struct") - assert.Nil(t, users) - }) -} - -func TestQueryByPage(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"count"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.NoError(t, err) - assert.Equal(t, 1, paginatedUsers.Total) - require.Len(t, paginatedUsers.Data, 1) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, paginatedUsers.Data[0]) - }) - - t.Run("ClosedConnForCountQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnError(sql.ErrConnDone) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, paginatedUsers) - }) - - t.Run("ClosedConnForDataQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"count"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnError(sql.ErrConnDone) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, paginatedUsers) - }) - - t.Run("ScanError", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT COUNT\(\*\) FROM user WHERE status != \?`). - WillReturnRows(mock.NewRows([]string{"count"}). - AddRow(1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT \?, \?`). - WillReturnRows(mock.NewRows([]string{"id", "name", "age", "status"}). - AddRow(1, "foo", 18, StatusNormal)) - paginatedUsers, err := QueryByPage[User](context.Background(), db, "user", Pagination{Index: 1, Size: 10}, nil, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "column age does not exist in struct") - assert.Nil(t, paginatedUsers) - }) -} - -func TestQueryFirst(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, *user) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, user) - }) - - t.Run("ClosedConn", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnError(sql.ErrConnDone) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - assert.Nil(t, user) - }) - - t.Run("ScanError", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "age", "status"}). - AddRow(1, "foo", 18, StatusNormal)) - user, err := QueryFirst[User](context.Background(), db, "user", nil, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "column age does not exist in struct") - assert.Nil(t, user) - }) -} - -func TestQueryByID(t *testing.T) { - type User struct { - ID string `db:"id"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - user, err := QueryByID[User](context.Background(), db, "user", "1") - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, User{ID: "1", Name: "foo", Status: StatusNormal}, *user) - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows(nil)) - user, err := QueryByID[User](context.Background(), db, "user", "1") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - assert.Nil(t, user) - }) -} - -func TestCreate(t *testing.T) { - type User struct { - ID int `db:"id"` - CTime time.Time `db:"c_time"` - UTime time.Time `db:"u_time"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO user \(.+\) VALUES \(\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow(1, "foo", StatusNormal)) - user, err := Create(context.Background(), db, "user", &User{Name: "foo", Status: StatusNormal}) - require.NoError(t, err) - require.NotNil(t, user) - assert.Equal(t, User{ID: 1, Name: "foo", Status: StatusNormal}, *user) - }) - - t.Run("InvalidType", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user int - _, err = Create(context.Background(), db, "user", &user) - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - }) - - t.Run("NoDBFields", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user struct{} - _, err = Create(context.Background(), db, "user", &user) - require.Error(t, err) - assert.EqualError(t, err, "no db fields found") - }) - - t.Run("ClosedConnForInsertQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO user \(.+\) VALUES \(\?,\?,\?,\?\)`). - WillReturnError(sql.ErrConnDone) - _, err = Create(context.Background(), db, "user", &User{Name: "foo", Status: StatusNormal}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) - - t.Run("ClosedConnForLastInsertID", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`INSERT INTO user \(.+\) VALUES \(\?,\?,\?,\?\)`). - WillReturnResult(sqlmock.NewErrorResult(sql.ErrConnDone)) - _, err = Create(context.Background(), db, "user", &User{Name: "foo", Status: StatusNormal}) - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} - -func TestUpdateByID(t *testing.T) { - type User struct { - ID string `db:"id"` - CTime time.Time `db:"c_time"` - UTime time.Time `db:"u_time"` - Name string `db:"name"` - Status Status `db:"status"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectQuery(`SELECT \* FROM user WHERE id = \? AND status != \? ORDER BY id ASC LIMIT 1`). - WillReturnRows(mock.NewRows([]string{"id", "name", "status"}). - AddRow("1", "foo", StatusNormal)) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.NoError(t, err) - }) - - t.Run("InvalidType", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user int - err = UpdateByID(context.Background(), db, "user", "1", &user, "name") - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - }) - - t.Run("ColumnNotExist", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user struct{} - err = UpdateByID(context.Background(), db, "user", "1", &user, "name") - require.Error(t, err) - assert.EqualError(t, err, "column name does not exist in struct") - }) - - t.Run("ReadOnlyColumn", func(t *testing.T) { - db, _, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var user User - err = UpdateByID(context.Background(), db, "user", "1", &user, "id") - require.Error(t, err) - assert.EqualError(t, err, "column id is read-only") - }) - - t.Run("NotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnResult(sqlmock.NewResult(0, 0)) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.Error(t, err) - assert.ErrorIs(t, err, ErrNotExist) - }) - - t.Run("ClosedConnForUpdateQuery", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnError(sql.ErrConnDone) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) - - t.Run("ClosedConnForRowsAffected", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectExec(`UPDATE user SET u_time=\?,name=\? WHERE id=\?`). - WithArgs(sqlmock.AnyArg(), "foo", "1"). - WillReturnResult(sqlmock.NewErrorResult(sql.ErrConnDone)) - err = UpdateByID(context.Background(), db, "user", "1", &User{Name: "foo"}, "name") - require.Error(t, err) - assert.ErrorIs(t, err, sql.ErrConnDone) - }) -} diff --git a/spx-backend/internal/model/scan.go b/spx-backend/internal/model/scan.go deleted file mode 100644 index 83f03c9ad..000000000 --- a/spx-backend/internal/model/scan.go +++ /dev/null @@ -1,73 +0,0 @@ -package model - -import ( - "database/sql" - "errors" - "fmt" - "reflect" -) - -// dbFieldsForRegisteredModels is a map of registered models to their database fields. -var dbFieldsForRegisteredModels = map[reflect.Type]map[string]reflect.StructField{ - reflect.TypeOf(Project{}): reflectModelDBFields(reflect.TypeOf(Project{})), - reflect.TypeOf(Asset{}): reflectModelDBFields(reflect.TypeOf(Asset{})), -} - -// reflectModelDBFields returns a map of database columns to struct fields based -// on the "db" tag. -// -// The "db" tag specifies the column name in the database and is required for -// each field that should be scanned. Fields without the "db" tag or unexported -// fields are ignored. -func reflectModelDBFields(t reflect.Type) map[string]reflect.StructField { - fields := reflect.VisibleFields(t) - m := make(map[string]reflect.StructField, len(fields)) - for _, field := range fields { - if !field.IsExported() { - continue - } - dbTag, ok := field.Tag.Lookup("db") - if !ok { - continue - } - m[dbTag] = field - } - return m -} - -// reflectModelItem returns the [reflect.Value] and database fields for a model item. -func reflectModelItem(item any) (itemValue reflect.Value, dbFields map[string]reflect.StructField, err error) { - itemValue = reflect.ValueOf(item).Elem() - itemType := itemValue.Type() - if itemType.Kind() != reflect.Struct { - return reflect.Value{}, nil, errors.New("item must be a struct") - } - dbFields, ok := dbFieldsForRegisteredModels[itemType] - if !ok { - dbFields = reflectModelDBFields(itemType) - } - return -} - -// rowsScan scans a single SQL row into a generic struct with automatic field mapping. -func rowsScan[T any](rows *sql.Rows) (T, error) { - var item T - itemValue, dbFields, err := reflectModelItem(&item) - if err != nil { - return item, err - } - - columns, err := rows.Columns() - if err != nil { - return item, err - } - dests := make([]any, len(columns)) - for i, col := range columns { - dbField, ok := dbFields[col] - if !ok { - return item, fmt.Errorf("column %s does not exist in struct", col) - } - dests[i] = itemValue.FieldByName(dbField.Name).Addr().Interface() - } - return item, rows.Scan(dests...) -} diff --git a/spx-backend/internal/model/scan_test.go b/spx-backend/internal/model/scan_test.go deleted file mode 100644 index e982273f0..000000000 --- a/spx-backend/internal/model/scan_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package model - -import ( - "reflect" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestReflectModelDBFields(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - } - fields := reflectModelDBFields(reflect.TypeOf(User{})) - require.NotNil(t, fields) - assert.Contains(t, fields, "id") - assert.Contains(t, fields, "name") - }) - - t.Run("UnexportedField", func(t *testing.T) { - type User struct { - ID int `db:"id"` - name string `db:"name"` - } - fields := reflectModelDBFields(reflect.TypeOf(User{})) - require.NotNil(t, fields) - assert.Contains(t, fields, "id") - assert.NotContains(t, fields, "name") - }) - - t.Run("NoDBTag", func(t *testing.T) { - type User struct { - ID int - Name string - } - fields := reflectModelDBFields(reflect.TypeOf(User{})) - require.NotNil(t, fields) - assert.Empty(t, fields) - }) -} - -func TestReflectModelItem(t *testing.T) { - t.Run("Normal", func(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - } - user := User{} - value, fields, err := reflectModelItem(&user) - require.NoError(t, err) - assert.NotZero(t, value) - assert.NotEmpty(t, fields) - }) - - t.Run("InvalidType", func(t *testing.T) { - var user int - value, fields, err := reflectModelItem(&user) - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - assert.Zero(t, value) - assert.Empty(t, fields) - }) -} - -func TestRowsScan(t *testing.T) { - type User struct { - ID int `db:"id"` - Name string `db:"name"` - } - - t.Run("Normal", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - var users []User - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, "foo"). - AddRow(2, "bar")) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - user, err := rowsScan[User](rows) - require.NoError(t, err) - assert.NotZero(t, user) - users = append(users, user) - } - require.Len(t, users, 2) - assert.Equal(t, User{ID: 1, Name: "foo"}, users[0]) - assert.Equal(t, User{ID: 2, Name: "bar"}, users[1]) - }) - - t.Run("InvalidType", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, "foo")) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - user, err := rowsScan[int](rows) - require.Error(t, err) - assert.EqualError(t, err, "item must be a struct") - assert.Zero(t, user) - } - }) - - t.Run("NULL", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, nil)) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - _, err := rowsScan[User](rows) - require.Error(t, err) - assert.Contains(t, err.Error(), "converting NULL to string is unsupported") - } - }) - - t.Run("ColumnNotExist", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name, age FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name", "age"}). - AddRow(1, "foo", 18)) - rows, err := db.Query("SELECT id, name, age FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - user, err := rowsScan[User](rows) - require.Error(t, err) - assert.EqualError(t, err, "column age does not exist in struct") - assert.Zero(t, user) - } - }) - - t.Run("ClosedRows", func(t *testing.T) { - db, mock, err := sqlmock.New() - require.NoError(t, err) - defer db.Close() - - mock.ExpectQuery("SELECT id, name FROM user"). - WillReturnRows(mock.NewRows([]string{"id", "name"}). - AddRow(1, "foo")) - rows, err := db.Query("SELECT id, name FROM user") - require.NoError(t, err) - defer rows.Close() - for rows.Next() { - require.NoError(t, rows.Close()) - user, err := rowsScan[User](rows) - require.Error(t, err) - assert.EqualError(t, err, "sql: Rows are closed") - assert.Zero(t, user) - } - }) -} diff --git a/spx-backend/internal/model/user.go b/spx-backend/internal/model/user.go new file mode 100644 index 000000000..c09cef2c0 --- /dev/null +++ b/spx-backend/internal/model/user.go @@ -0,0 +1,66 @@ +package model + +import ( + "context" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// User is the model for users. +type User struct { + Model + + // Username is the unique username. + Username string `gorm:"column:username;index:,unique,where:deleted_at IS NULL"` + + // Description is the brief bio or description. + Description string `gorm:"column:description"` + + // FollowerCount is the number of followers the user has. + FollowerCount int64 `gorm:"column:follower_count"` + + // FollowingCount is the number of users the user is following. + FollowingCount int64 `gorm:"column:following_count"` + + // ProjectCount is the total number of projects created by the user. + ProjectCount int64 `gorm:"column:project_count"` + + // PublicProjectCount is the number of public projects created by the user. + PublicProjectCount int64 `gorm:"column:public_project_count"` + + // LikedProjectCount is the number of projects liked by the user. + LikedProjectCount int64 `gorm:"column:liked_project_count"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (User) TableName() string { + return "user" +} + +// FirstOrCreateUser gets or creates a user. +func FirstOrCreateUser(ctx context.Context, db *gorm.DB, username string) (*User, error) { + var mUser User + if err := db.WithContext(ctx). + Where("username = ?", username). + Attrs(User{Username: username}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "username"}}, + DoNothing: true, + }). + FirstOrCreate(&mUser). + Error; err != nil { + return nil, fmt.Errorf("failed to get/create user %s: %w", username, err) + } + if mUser.ID == 0 { + // Unfortunatlly, MySQL doesn't support the RETURNING clause. + if err := db.WithContext(ctx). + Where("username = ?", username). + First(&mUser). + Error; err != nil { + return nil, fmt.Errorf("failed to get user %s: %w", username, err) + } + } + return &mUser, nil +} diff --git a/spx-backend/internal/model/user_project_relationship.go b/spx-backend/internal/model/user_project_relationship.go new file mode 100644 index 000000000..d114500c8 --- /dev/null +++ b/spx-backend/internal/model/user_project_relationship.go @@ -0,0 +1,69 @@ +package model + +import ( + "context" + "database/sql" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UserProjectRelationship is the model for user-project relationships. +type UserProjectRelationship struct { + Model + + // UserID is the ID of the user involved in the relationship. + UserID int64 `gorm:"column:user_id;index;index:,composite:user_id_project_id,unique"` + User User `gorm:"foreignKey:UserID"` + + // ProjectID is the ID of the project involved in the relationship. + ProjectID int64 `gorm:"column:project_id;index;index:,composite:user_id_project_id,unique"` + Project Project `gorm:"foreignKey:ProjectID"` + + // ViewCount is the number of times the user has viewed the project. + ViewCount int64 `gorm:"column:view_count;index"` + + // LastViewedAt is the time when the user last viewed the project. + // + // If LastViewedAt.Valid is false, it means the user has not viewed the project. + LastViewedAt sql.NullTime `gorm:"column:last_viewed_at;index"` + + // LikedAt is the time when the user liked the project. + // + // If LikedAt.Valid is false, it means the user has not liked the project. + LikedAt sql.NullTime `gorm:"column:liked_at;index"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (UserProjectRelationship) TableName() string { + return "user_project_relationship" +} + +// FirstOrCreateUserProjectRelationship gets or creates a user-project relationship. +func FirstOrCreateUserProjectRelationship(ctx context.Context, db *gorm.DB, userID, projectID int64) (*UserProjectRelationship, error) { + var mUserProjectRelationship UserProjectRelationship + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("project_id = ?", projectID). + Attrs(UserProjectRelationship{UserID: userID, ProjectID: projectID}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "project_id"}}, + DoNothing: true, + }). + FirstOrCreate(&mUserProjectRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get/create user-project relationship: %w", err) + } + if mUserProjectRelationship.ID == 0 { + // Unfortunatlly, MySQL doesn't support the RETURNING clause. + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("project_id = ?", projectID). + First(&mUserProjectRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get user-project relationship: %w", err) + } + } + return &mUserProjectRelationship, nil +} diff --git a/spx-backend/internal/model/user_relationship.go b/spx-backend/internal/model/user_relationship.go new file mode 100644 index 000000000..e8299a0bd --- /dev/null +++ b/spx-backend/internal/model/user_relationship.go @@ -0,0 +1,62 @@ +package model + +import ( + "context" + "database/sql" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UserRelationship is the model for user relationships. +type UserRelationship struct { + Model + + // UserID is the ID of the user involved in the relationship. + UserID int64 `gorm:"column:user_id;index;index:,composite:user_id_target_user_id,unique"` + User User `gorm:"foreignKey:UserID"` + + // TargetUserID is the ID of the target user involved in the relationship. + TargetUserID int64 `gorm:"column:target_user_id;index;index:,composite:user_id_target_user_id,unique"` + TargetUser User `gorm:"foreignKey:TargetUserID"` + + // FollowedAt is the time when the user followed the target user. + // + // If FollowedAt.Valid is false, it means the user is not following the + // target user. + FollowedAt sql.NullTime `gorm:"column:followed_at;index"` +} + +// TableName implements [gorm.io/gorm/schema.Tabler]. +func (UserRelationship) TableName() string { + return "user_relationship" +} + +// FirstOrCreateUserRelationship gets or creates a user relationship. +func FirstOrCreateUserRelationship(ctx context.Context, db *gorm.DB, userID, targetUserID int64) (*UserRelationship, error) { + var mUserRelationship UserRelationship + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("target_user_id = ?", targetUserID). + Attrs(UserRelationship{UserID: userID, TargetUserID: targetUserID}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "target_user_id"}}, + DoNothing: true, + }). + FirstOrCreate(&mUserRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get/create user relationship: %w", err) + } + if mUserRelationship.ID == 0 { + // Unfortunatlly, MySQL doesn't support the RETURNING clause. + if err := db.WithContext(ctx). + Where("user_id = ?", userID). + Where("target_user_id = ?", targetUserID). + First(&mUserRelationship). + Error; err != nil { + return nil, fmt.Errorf("failed to get user relationship: %w", err) + } + } + return &mUserRelationship, nil +} diff --git a/spx-gui/src/apis/asset.ts b/spx-gui/src/apis/asset.ts index ab59facff..4366af5e3 100644 --- a/spx-gui/src/apis/asset.ts +++ b/spx-gui/src/apis/asset.ts @@ -1,40 +1,36 @@ import type { FileCollection, ByPage, PaginationParams } from './common' -import { client, IsPublic } from './common' +import { client, Visibility } from './common' -export { IsPublic } +export { Visibility } export enum AssetType { - Sprite = 0, - Backdrop = 1, - Sound = 2 + Sprite = 'sprite', + Backdrop = 'backdrop', + Sound = 'sound' } export type AssetData = { - /** Globally unique ID */ + /** Unique identifier */ id: string - /** Name to display */ - displayName: string - /** Name of asset owner */ + /** Username of the asset's owner */ owner: string - /** Asset Category */ + /** Display name of the asset */ + displayName: string + /** Type of the asset */ + type: AssetType + /** Category to which the asset belongs */ category: string - /** Asset Type */ - assetType: AssetType - /** Files the asset contains */ + /** File paths and their corresponding universal URLs associated with the asset */ files: FileCollection - /** Hash of the files */ + /** Hash of the asset files */ filesHash: string - /** Preview URL for the asset, e.g., a gif for a sprite */ - preview: string - /** Click count of the asset */ - clickCount: number - /** Public status */ - isPublic: IsPublic + /** Visibility of the asset */ + visibility: Visibility } export type AddAssetParams = Pick< AssetData, - 'displayName' | 'category' | 'assetType' | 'files' | 'filesHash' | 'preview' | 'isPublic' + 'displayName' | 'type' | 'category' | 'files' | 'filesHash' | 'visibility' > export function addAsset(params: AddAssetParams) { @@ -51,20 +47,15 @@ export function deleteAsset(id: string) { return client.delete(`/asset/${encodeURIComponent(id)}`) as Promise } -export enum ListAssetParamOrderBy { - Default = 'default', - TimeDesc = 'time', - ClickCountDesc = 'clickCount' -} - export type ListAssetParams = PaginationParams & { keyword?: string owner?: string + type?: AssetType category?: string - assetType?: AssetType filesHash?: string - isPublic?: IsPublic - orderBy?: ListAssetParamOrderBy + visibility?: Visibility + orderBy?: 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export function listAsset(params?: ListAssetParams) { diff --git a/spx-gui/src/apis/common/index.ts b/spx-gui/src/apis/common/index.ts index 55b684f16..78b21fc46 100644 --- a/spx-gui/src/apis/common/index.ts +++ b/spx-gui/src/apis/common/index.ts @@ -12,9 +12,9 @@ export type ByPage = { export const ownerAll = '*' -export enum IsPublic { - personal = 0, - public = 1 +export enum Visibility { + Private = 'private', + Public = 'public' } /** Url with 'http:', 'https:', or 'data:' schemes, used for web resources that can be accessed directly via `fetch()` */ diff --git a/spx-gui/src/apis/project.ts b/spx-gui/src/apis/project.ts index b0361a4ba..c8b0a6e8e 100644 --- a/spx-gui/src/apis/project.ts +++ b/spx-gui/src/apis/project.ts @@ -1,9 +1,9 @@ import dayjs from 'dayjs' import type { FileCollection, ByPage, PaginationParams } from './common' -import { client, IsPublic, ownerAll, timeStringify } from './common' +import { client, Visibility, ownerAll, timeStringify } from './common' import { ApiException, ApiExceptionCode } from './common/exception' -export { IsPublic, ownerAll } +export { Visibility, ownerAll } export enum ProjectDataType { Sprite = 0, @@ -12,20 +12,24 @@ export enum ProjectDataType { } export type ProjectData = { - /** Globally Unique ID */ + /** Unique identifier */ id: string - /** Project name, unique for projects of same owner */ - name: string - /** Name of project owner */ + /** Creation timestamp */ + createdAt: string + /** Last update timestamp */ + updatedAt: string + /** Unique username of the user */ owner: string - /** Public status */ - isPublic: IsPublic - /** Files the project contains */ - files: FileCollection - /** Project version */ - version: number /** Full name of the project release from which the project is remixed */ remixedFrom: string + /** Unique name of the project */ + name: string + /** Version number of the project */ + version: number + /** File paths and their corresponding universal URLs associated with the project */ + files: FileCollection + /** Visibility of the project */ + visibility: Visibility /** Brief description of the project */ description: string /** Instructions on how to interact with the project */ @@ -40,10 +44,6 @@ export type ProjectData = { releaseCount: number /** Number of remixes associated with the project */ remixCount: number - /** Create time */ - cTime: string - /** Update time */ - uTime: string } // TODO: remove me @@ -75,13 +75,13 @@ function __adaptProjectData(p: unknown): ProjectData { } } -export type AddProjectParams = Pick +export type AddProjectParams = Pick export async function addProject(params: AddProjectParams, signal?: AbortSignal) { return __adaptProjectData(await client.post('/project', params, { signal })) } -export type UpdateProjectParams = Pick +export type UpdateProjectParams = Pick function encode(owner: string, name: string) { return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}` @@ -101,11 +101,15 @@ export function deleteProject(owner: string, name: string) { } export type ListProjectParams = PaginationParams & { - isPublic?: IsPublic - /** Name of project owner, `*` indicates projects of all users */ + /** + * Filter projects by the owner's username. + * Defaults to the authenticated user if not specified. Use * to include projects from all users. + **/ owner?: string /** Filter projects by name pattern */ keyword?: string + /** Filter projects by visibility */ + visibility?: Visibility /** Filter projects that were created after this timestamp */ createdAfter?: string /** Filter projects that gained new likes after this timestamp */ @@ -115,7 +119,13 @@ export type ListProjectParams = PaginationParams & { /** If filter projects created by followees of logged-in user */ fromFollowees?: boolean /** Field by which to order the results */ - orderBy?: 'cTime' | 'uTime' | 'likeCount' | 'remixCount' | 'recentLikeCount' | 'recentRemixCount' + orderBy?: + | 'createdAt' + | 'updatedAt' + | 'likeCount' + | 'remixCount' + | 'recentLikeCount' + | 'recentRemixCount' /** Order in which to sort the results */ sortOrder?: 'asc' | 'desc' } @@ -150,7 +160,7 @@ export async function exploreProjects({ order, count }: ExploreParams) { // count within the last month const countAfter = timeStringify(dayjs().subtract(1, 'month').valueOf()) const p: ListProjectParams = { - isPublic: IsPublic.public, + visibility: Visibility.Public, owner: ownerAll, pageSize: count, pageIndex: 1 @@ -169,7 +179,7 @@ export async function exploreProjects({ order, count }: ExploreParams) { case ExploreOrder.FollowingCreated: p.fromFollowees = true p.createdAfter = countAfter - p.orderBy = 'cTime' + p.orderBy = 'createdAt' p.sortOrder = 'desc' break } @@ -185,7 +195,7 @@ export async function isLiking(owner: string, name: string) { // TOOD: remove me if (process.env.NODE_ENV === 'development') return Math.random() > 0.5 try { - await client.get(`/project/liking/${encode(owner, name)}`) + await client.get(`/project/${encode(owner, name)}/liking`) return true } catch (e) { if (e instanceof ApiException) { diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index 2a4952db5..80c72a242 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -3,6 +3,10 @@ import { client } from './common' export type User = { /** Unique identifier */ id: string + /** Creation timestamp */ + createdAt: string + /** Last update timestamp */ + updatedAt: string /** Unique username of the user */ username: string /** Brief bio or description of the user */ @@ -11,10 +15,6 @@ export type User = { displayName: string /** Avatar URL, TODO: from Casdoor? */ avatar: string - /** Create time */ - cTime: string - /** Update time */ - uTime: string } export async function getUser(name: string): Promise { @@ -22,12 +22,12 @@ export async function getUser(name: string): Promise { if (process.env.NODE_ENV === 'development') return { id: '1', + createdAt: '2021-08-07T07:00:00Z', + updatedAt: '2021-08-07T07:00:00Z', username: name, description: 'This is description of ' + name, displayName: name, - avatar: 'https://avatars.githubusercontent.com/u/1492263?v=4', - cTime: '2021-08-07T07:00:00Z', - uTime: '2021-08-07T07:00:00Z' + avatar: 'https://avatars.githubusercontent.com/u/1492263?v=4' } return client.get(`/user/${encodeURIComponent(name)}`) as Promise } diff --git a/spx-gui/src/components/asset/library/AssetAddModal.vue b/spx-gui/src/components/asset/library/AssetAddModal.vue index dcd2a9001..65f24b49c 100644 --- a/spx-gui/src/components/asset/library/AssetAddModal.vue +++ b/spx-gui/src/components/asset/library/AssetAddModal.vue @@ -33,9 +33,9 @@ - + @@ -61,14 +61,7 @@ import { useForm, useConfirmDialog } from '@/components/ui' -import { - type AssetData, - addAsset, - listAsset, - IsPublic, - ListAssetParamOrderBy, - AssetType -} from '@/apis/asset' +import { type AssetData, addAsset, listAsset, Visibility, AssetType } from '@/apis/asset' import { useMessageHandle } from '@/utils/exception' import { Backdrop } from '@/models/backdrop' import { Sound } from '@/models/sound' @@ -100,7 +93,7 @@ const { t } = useI18n() const form = useForm({ name: [props.asset.name, validateName], category: [categoryAll.value], - isPublic: [false] + visibilityPublic: [false] }) const withConfirm = useConfirmDialog() @@ -126,13 +119,14 @@ const handleSubmit = useMessageHandle( const { data: assets } = await listAsset({ pageSize: 1, // we only need to know if the asset with the same filesHash exists pageIndex: 1, - assetType: params.assetType, + type: params.type, filesHash: params.filesHash, - orderBy: ListAssetParamOrderBy.TimeDesc + orderBy: 'createdAt', + sortOrder: 'desc' }) if (assets.length) { let assetTypeName = t({ en: 'asset', zh: '素材' }) - switch (params.assetType) { + switch (params.type) { case AssetType.Sprite: assetTypeName = t({ en: 'sprite', zh: '精灵' }) break @@ -160,8 +154,7 @@ const handleSubmit = useMessageHandle( ...params, displayName: form.value.name, category: form.value.category, - preview: 'TODO', - isPublic: form.value.isPublic ? IsPublic.public : IsPublic.personal + visibility: form.value.visibilityPublic ? Visibility.Public : Visibility.Private }) emit('resolved') return assetData diff --git a/spx-gui/src/components/asset/library/AssetLibraryModal.vue b/spx-gui/src/components/asset/library/AssetLibraryModal.vue index 097d5a50b..5aa08c386 100644 --- a/spx-gui/src/components/asset/library/AssetLibraryModal.vue +++ b/spx-gui/src/components/asset/library/AssetLibraryModal.vue @@ -106,7 +106,7 @@ import { UISearchableModal, UIDivider } from '@/components/ui' -import { listAsset, AssetType, type AssetData, IsPublic } from '@/apis/asset' +import { listAsset, AssetType, type AssetData, Visibility } from '@/apis/asset' import { debounce } from 'lodash' import { useMessageHandle, useQuery } from '@/utils/exception' import { type Category, categories as categoriesWithoutAll, categoryAll } from './category' @@ -165,13 +165,13 @@ const { const c = category.value.value const cPersonal = categoryPersonal.value.value return listAsset({ - pageSize: 500, // try to get all + pageSize: 100, // try to get all pageIndex: 1, - assetType: props.type, + type: props.type, keyword: keyword.value, category: c === categoryAll.value || c === cPersonal ? undefined : c, owner: c === cPersonal ? undefined : '*', - isPublic: c === cPersonal ? undefined : IsPublic.public + visibility: c === cPersonal ? undefined : Visibility.Public }) }, { @@ -191,7 +191,7 @@ function handleSelectCategory(c: Category) { const selected = shallowReactive([]) async function addAssetToProject(asset: AssetData) { - switch (asset.assetType) { + switch (asset.type) { case AssetType.Sprite: { const sprite = await asset2Sprite(asset) props.project.addSprite(sprite) diff --git a/spx-gui/src/components/editor/navbar/EditorNavbar.vue b/spx-gui/src/components/editor/navbar/EditorNavbar.vue index 4acbd5d38..419fad53f 100644 --- a/spx-gui/src/components/editor/navbar/EditorNavbar.vue +++ b/spx-gui/src/components/editor/navbar/EditorNavbar.vue @@ -33,7 +33,7 @@ {{ $t({ en: 'Share project', zh: '分享项目' }) }} @@ -100,7 +100,7 @@ import { useI18n, type LocaleMessage } from '@/utils/i18n' import { useNetwork } from '@/utils/network' import { selectFile } from '@/utils/file' import { AutoSaveToCloudState, type Project } from '@/models/project' -import { IsPublic } from '@/apis/common' +import { Visibility } from '@/apis/common' import { useRemoveProject, useShareProject, useStopSharingProject } from '@/components/project' import { useLoadFromScratchModal } from '@/components/asset' import NavbarWrapper from '@/components/navbar/NavbarWrapper.vue' diff --git a/spx-gui/src/components/project/ProjectCreateModal.vue b/spx-gui/src/components/project/ProjectCreateModal.vue index b0c9f2bb0..1cb90aff8 100644 --- a/spx-gui/src/components/project/ProjectCreateModal.vue +++ b/spx-gui/src/components/project/ProjectCreateModal.vue @@ -44,7 +44,7 @@ import { useForm, type FormValidationResult } from '@/components/ui' -import { type ProjectData, getProject, addProject, IsPublic } from '@/apis/project' +import { type ProjectData, getProject, addProject, Visibility } from '@/apis/project' import { useI18n } from '@/utils/i18n' import { useMessageHandle } from '@/utils/exception' import { useUserStore } from '@/stores/user' @@ -103,7 +103,7 @@ const handleSubmit = useMessageHandle( const { fileCollection } = await saveFiles(files) const projectData = await addProject({ name: form.value.name, - isPublic: IsPublic.personal, + visibility: Visibility.Private, files: fileCollection }) emit('resolved', projectData) diff --git a/spx-gui/src/components/project/index.ts b/spx-gui/src/components/project/index.ts index c97bdfbf4..cf177dff1 100644 --- a/spx-gui/src/components/project/index.ts +++ b/spx-gui/src/components/project/index.ts @@ -1,5 +1,5 @@ import { useModal, useConfirmDialog } from '@/components/ui' -import { IsPublic, deleteProject } from '@/apis/project' +import { Visibility, deleteProject } from '@/apis/project' import ProjectCreateModal from './ProjectCreateModal.vue' import ProjectOpenModal from './ProjectOpenModal.vue' import ProjectSharingLinkModal from './ProjectSharingLinkModal.vue' @@ -57,12 +57,12 @@ export function useShareProject() { const createProjectSharingLink = useCreateProjectSharingLink() async function makePublic(project: Project) { - project.setPublic(IsPublic.public) + project.setVisibility(Visibility.Public) await project.saveToCloud() } return async function shareProject(project: Project) { - if (project.isPublic !== IsPublic.public) { + if (project.visibility !== Visibility.Public) { await withConfirm({ title: t({ en: 'Share project', zh: '分享项目' }), content: t({ @@ -80,8 +80,8 @@ export function useStopSharingProject() { const { t } = useI18n() const withConfirm = useConfirmDialog() - async function makePersonal(project: Project) { - project.setPublic(IsPublic.personal) + async function makePrivate(project: Project) { + project.setVisibility(Visibility.Private) await project.saveToCloud() } @@ -92,7 +92,7 @@ export function useStopSharingProject() { en: 'If sharing is stopped, others will no longer have access to the current project, and its sharing links will expire. Would you like to proceed?', zh: '如果停止分享,其他人将无法访问当前项目,且分享链接将会失效。确认继续吗?' }), - confirmHandler: () => makePersonal(project) + confirmHandler: () => makePrivate(project) }) } } diff --git a/spx-gui/src/components/project/item/ProjectItem.vue b/spx-gui/src/components/project/item/ProjectItem.vue index f929375c1..f9384b563 100644 --- a/spx-gui/src/components/project/item/ProjectItem.vue +++ b/spx-gui/src/components/project/item/ProjectItem.vue @@ -24,7 +24,7 @@
{{ project.name }}