diff --git a/dal/query/export_db.go b/dal/query/export_db.go index bac903af..46ca5d6c 100644 --- a/dal/query/export_db.go +++ b/dal/query/export_db.go @@ -18,6 +18,6 @@ import ( "gorm.io/gorm" ) -func (q *Query) DB() *gorm.DB { +func (q *Query) RawDB() *gorm.DB { return q.db } diff --git a/internal/auth/mysql_repository_test.go b/internal/auth/mysql_repository_test.go index 0320de7a..6c790712 100644 --- a/internal/auth/mysql_repository_test.go +++ b/internal/auth/mysql_repository_test.go @@ -37,7 +37,7 @@ import ( func getRepo(t *testing.T) (auth.Repo, *query.Query) { t.Helper() q := query.Use(test.GetGorm(t)) - repo := auth.NewMysqlRepo(q, zap.NewNop(), sqlx.NewDb(lo.Must(q.DB().DB()), "mysql")) + repo := auth.NewMysqlRepo(q, zap.NewNop(), sqlx.NewDb(lo.Must(q.RawDB().DB()), "mysql")) return repo, q } diff --git a/internal/collections/domain.go b/internal/collections/domain.go index 31517298..c37a327d 100644 --- a/internal/collections/domain.go +++ b/internal/collections/domain.go @@ -64,6 +64,10 @@ type Repo interface { //nolint:interfacebloat update func(ctx context.Context, s *collection.Subject) (*collection.Subject, error), ) error + DeleteSubjectCollection( + ctx context.Context, userID model.UserID, subjectID model.SubjectID, + ) error + UpdateEpisodeCollection( ctx context.Context, userID model.UserID, subjectID model.SubjectID, diff --git a/internal/collections/infra/mysql_repo.go b/internal/collections/infra/mysql_repo.go index e0375d15..438b184c 100644 --- a/internal/collections/infra/mysql_repo.go +++ b/internal/collections/infra/mysql_repo.go @@ -121,6 +121,30 @@ func (r mysqlRepo) UpdateOrCreateSubjectCollection( return r.updateOrCreateSubjectCollection(ctx, userID, subject, at, ip, update, s) } +func (r mysqlRepo) DeleteSubjectCollection( + ctx context.Context, + userID model.UserID, + subjectID model.SubjectID, +) error { + err := r.q.RawDB().Exec(` + UPDATE chii_subject_interests set interest_type = 0, + interest_rate = 0, + interest_comment = 0, + interest_has_comment = 0, + interest_tag = '', + interest_ep_status = 0, + interest_vol_status = 0, + interest_wish_dateline = 0, + interest_doing_dateline = 0, + interest_collect_dateline = 0, + interest_on_hold_dateline = 0, + interest_dropped_dateline = 0 + where interest_uid = ? and interest_subject_id = ? +`, userID, subjectID).Error + + return err +} + //nolint:funlen func (r mysqlRepo) updateOrCreateSubjectCollection( ctx context.Context, @@ -308,7 +332,7 @@ func (r mysqlRepo) reCountSubjectTags(ctx context.Context, tx *query.Query, return true }) - db := tx.DB().WithContext(ctx) + db := tx.RawDB().WithContext(ctx) err = db.Exec(` update chii_tag_neue_index as ti @@ -554,7 +578,7 @@ func (r mysqlRepo) reCountSubjectCollection(ctx context.Context, subjectID model } return r.q.Transaction(func(tx *query.Query) error { - err := tx.DB().WithContext(ctx).Raw(` + err := tx.RawDB().WithContext(ctx).Raw(` select interest_type as type, count(interest_type) as total from chii_subject_interests where interest_subject_id = ? group by interest_type @@ -603,7 +627,7 @@ func (r mysqlRepo) reCountSubjectRate(ctx context.Context, subjectID model.Subje for _, rate := range []uint8{before, after} { var count uint32 if rate != 0 { - err := tx.DB().WithContext(ctx).Raw(` + err := tx.RawDB().WithContext(ctx).Raw(` select count(*) from chii_subject_interests where interest_subject_id = ? and interest_private = 0 and interest_rate = ? `, subjectID, rate).Scan(&count).Error diff --git a/internal/collections/infra/mysql_repo_test.go b/internal/collections/infra/mysql_repo_test.go index b464faee..d2836bcd 100644 --- a/internal/collections/infra/mysql_repo_test.go +++ b/internal/collections/infra/mysql_repo_test.go @@ -242,7 +242,7 @@ func TestMysqlRepo_UpdateOrCreateSubjectCollection(t *testing.T) { now := time.Now() - // DB 里没有数据 + // RawDB 里没有数据 _, err = table.WithContext(context.TODO()).Where(table.SubjectID.Eq(sid), table.UserID.Eq(uid)).Take() require.Error(t, err) @@ -253,7 +253,7 @@ func TestMysqlRepo_UpdateOrCreateSubjectCollection(t *testing.T) { }) require.NoError(t, err) - // DB 里有数据 + // RawDB 里有数据 _, err = table.WithContext(context.TODO()).Where(table.SubjectID.Eq(sid), table.UserID.Eq(uid)).Take() require.NoError(t, err) @@ -442,6 +442,37 @@ func TestMysqlRepo_UpdateSubjectCollectionType(t *testing.T) { require.Zero(t, r.OnHoldTime) } +func TestMysqlRepo_DeleteSubjectCollection(t *testing.T) { + t.Parallel() + test.RequireEnv(t, test.EnvMysql) + + const id model.UserID = 30000 + const subjectID model.SubjectID = 10000 + + repo, q := getRepo(t) + + test.RunAndCleanup(t, func() { + _, err := q.WithContext(context.Background()).SubjectCollection. + Where(q.SubjectCollection.SubjectID.Eq(subjectID), q.SubjectCollection.UserID.Eq(id)).Delete() + require.NoError(t, err) + }) + + err := q.WithContext(context.Background()).SubjectCollection.Create(&dao.SubjectCollection{ + UserID: id, + SubjectID: subjectID, + Rate: 2, + }) + require.NoError(t, err) + + err = repo.DeleteSubjectCollection(context.Background(), id, subjectID) + require.NoError(t, err) + + r, err := q.WithContext(context.Background()).SubjectCollection. + Where(q.SubjectCollection.SubjectID.Eq(subjectID), q.SubjectCollection.UserID.Eq(id)).Take() + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + require.Nil(t, r) +} + func TestMysqlRepo_UpdateEpisodeCollection(t *testing.T) { test.RequireEnv(t, test.EnvMysql) t.Parallel() diff --git a/internal/mocks/CollectionRepo.go b/internal/mocks/CollectionRepo.go index 3fbafd13..c7ce9f66 100644 --- a/internal/mocks/CollectionRepo.go +++ b/internal/mocks/CollectionRepo.go @@ -197,6 +197,54 @@ func (_c *CollectionRepo_CountSubjectCollections_Call) RunAndReturn(run func(con return _c } +// DeleteSubjectCollection provides a mock function with given fields: ctx, userID, subjectID +func (_m *CollectionRepo) DeleteSubjectCollection(ctx context.Context, userID uint32, subjectID uint32) error { + ret := _m.Called(ctx, userID, subjectID) + + if len(ret) == 0 { + panic("no return value specified for DeleteSubjectCollection") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uint32, uint32) error); ok { + r0 = rf(ctx, userID, subjectID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// CollectionRepo_DeleteSubjectCollection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSubjectCollection' +type CollectionRepo_DeleteSubjectCollection_Call struct { + *mock.Call +} + +// DeleteSubjectCollection is a helper method to define mock.On call +// - ctx context.Context +// - userID uint32 +// - subjectID uint32 +func (_e *CollectionRepo_Expecter) DeleteSubjectCollection(ctx interface{}, userID interface{}, subjectID interface{}) *CollectionRepo_DeleteSubjectCollection_Call { + return &CollectionRepo_DeleteSubjectCollection_Call{Call: _e.mock.On("DeleteSubjectCollection", ctx, userID, subjectID)} +} + +func (_c *CollectionRepo_DeleteSubjectCollection_Call) Run(run func(ctx context.Context, userID uint32, subjectID uint32)) *CollectionRepo_DeleteSubjectCollection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint32), args[2].(uint32)) + }) + return _c +} + +func (_c *CollectionRepo_DeleteSubjectCollection_Call) Return(_a0 error) *CollectionRepo_DeleteSubjectCollection_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *CollectionRepo_DeleteSubjectCollection_Call) RunAndReturn(run func(context.Context, uint32, uint32) error) *CollectionRepo_DeleteSubjectCollection_Call { + _c.Call.Return(run) + return _c +} + // GetPersonCollection provides a mock function with given fields: ctx, userID, cat, targetID func (_m *CollectionRepo) GetPersonCollection(ctx context.Context, userID uint32, cat collection.PersonCollectCategory, targetID uint32) (collection.UserPersonCollection, error) { ret := _m.Called(ctx, userID, cat, targetID) diff --git a/internal/tag/mysql_repo_test.go b/internal/tag/mysql_repo_test.go index 31cc1585..e1cf92d1 100644 --- a/internal/tag/mysql_repo_test.go +++ b/internal/tag/mysql_repo_test.go @@ -32,7 +32,7 @@ import ( func getRepo(t *testing.T) tag.Repo { t.Helper() q := query.Use(test.GetGorm(t)) - repo, err := tag.NewMysqlRepo(q, zap.NewNop(), sqlx.NewDb(lo.Must(q.DB().DB()), "mysql")) + repo, err := tag.NewMysqlRepo(q, zap.NewNop(), sqlx.NewDb(lo.Must(q.RawDB().DB()), "mysql")) require.NoError(t, err) return repo diff --git a/openapi/v0.yaml b/openapi/v0.yaml index 87e18ee9..99cef4c1 100644 --- a/openapi/v0.yaml +++ b/openapi/v0.yaml @@ -1233,6 +1233,40 @@ paths: "$ref": "#/components/schemas/ErrorDetail" security: - OptionalHTTPBearer: [] + delete: + tags: + - 收藏 + summary: 删除用户单个收藏 + description: | + 删除条目收藏状态 + + 会删除当前填写的评分、tags 和吐槽,不会影响章节进度 + operationId: deleteUserCollection + parameters: + - $ref: "#/components/parameters/path_subject_id" + responses: + "204": + description: Successful Response + "400": + description: Validation Error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorDetail" + "401": + description: Unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorDetail" + "404": + description: 用户不存在或者条目未收藏 + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorDetail" + security: + - OptionalHTTPBearer: [] "/v0/users/-/collections/{subject_id}/episodes": get: diff --git a/web/handler/user/delete_subject_collection.go b/web/handler/user/delete_subject_collection.go new file mode 100644 index 00000000..0bf76658 --- /dev/null +++ b/web/handler/user/delete_subject_collection.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see + +package user + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/bangumi/server/domain/gerr" + "github.com/bangumi/server/internal/model" + "github.com/bangumi/server/web/accessor" + "github.com/bangumi/server/web/req" + "github.com/bangumi/server/web/res" +) + +func (h User) DeleteSubjectCollection(c echo.Context) error { + subjectID, err := req.ParseID(c.Param("subject_id")) + if err != nil { + return err + } + + return h.deleteSubjectCollection(c, subjectID) +} + +func (h User) deleteSubjectCollection(c echo.Context, subjectID model.SubjectID) error { + u := accessor.GetFromCtx(c) + + err := h.collect.DeleteSubjectCollection(c.Request().Context(), u.ID, subjectID) + if err != nil { + switch { + case errors.Is(err, gerr.ErrSubjectNotCollected): + return res.JSONError(c, err) + default: + return err + } + } + + return c.NoContent(http.StatusNoContent) +} diff --git a/web/handler/user/delete_subject_collection_test.go b/web/handler/user/delete_subject_collection_test.go new file mode 100644 index 00000000..9aef126d --- /dev/null +++ b/web/handler/user/delete_subject_collection_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see + +package user_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/mock" + "github.com/trim21/htest" + + "github.com/bangumi/server/internal/auth" + "github.com/bangumi/server/internal/mocks" + "github.com/bangumi/server/internal/model" + "github.com/bangumi/server/internal/pkg/test" +) + +func TestUser_DeleteSubjectCollection(t *testing.T) { + t.Parallel() + const uid model.UserID = 1 + const sid model.SubjectID = 8 + + a := mocks.NewAuthService(t) + a.EXPECT().GetByToken(mock.Anything, mock.Anything).Return(auth.Auth{ID: uid}, nil) + + c := mocks.NewCollectionRepo(t) + c.EXPECT().DeleteSubjectCollection(mock.Anything, uid, sid). + Return(nil) + + app := test.GetWebApp(t, test.Mock{CollectionRepo: c, AuthService: a}) + + htest.New(t, app). + Header(echo.HeaderAuthorization, "Bearer t"). + Delete(fmt.Sprintf("/v0/users/-/collections/%d", sid)). + ExpectCode(http.StatusNoContent) +} diff --git a/web/routes.go b/web/routes.go index 2c3808fd..f908c44a 100644 --- a/web/routes.go +++ b/web/routes.go @@ -95,6 +95,7 @@ func AddRouters( v0.GET("/users/-/collections/:subject_id/episodes", userHandler.GetSubjectEpisodeCollection, mw.NeedLogin) v0.PATCH("/users/-/collections/:subject_id", userHandler.PatchSubjectCollection, req.JSON, mw.NeedLogin) v0.POST("/users/-/collections/:subject_id", userHandler.PostSubjectCollection, req.JSON, mw.NeedLogin) + v0.DELETE("/users/-/collections/:subject_id", userHandler.DeleteSubjectCollection, mw.NeedLogin) v0.PATCH("/users/-/collections/:subject_id/episodes", userHandler.PatchEpisodeCollectionBatch, req.JSON, mw.NeedLogin)