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)