diff --git a/backend/app/http/admin/medias.go b/backend/app/http/admin/medias.go index 03b499e..2c68498 100644 --- a/backend/app/http/admin/medias.go +++ b/backend/app/http/admin/medias.go @@ -38,3 +38,22 @@ func (ctl *medias) Show(ctx fiber.Ctx, id int64) error { return ctx.Redirect().To(url) } + +// Delete +// @Router /v1/admin/medias/:id [delete] +// @Bind id path +func (ctl *medias) Delete(ctx fiber.Ctx, id int64) error { + media, err := models.Medias.GetByID(ctx.Context(), id) + if err != nil { + return ctx.SendString("Media not found") + } + + if err := ctl.oss.Delete(ctx.Context(), media.Path); err != nil { + return err + } + + if err := models.Medias.Delete(ctx.Context(), id); err != nil { + return err + } + return ctx.SendStatus(fiber.StatusNoContent) +} diff --git a/backend/app/http/admin/posts.go b/backend/app/http/admin/posts.go index f353042..4d23f3e 100644 --- a/backend/app/http/admin/posts.go +++ b/backend/app/http/admin/posts.go @@ -23,7 +23,32 @@ type posts struct{} // @Bind query query func (ctl *posts) List(ctx fiber.Ctx, pagination *requests.Pagination, query *ListQuery) (*requests.Pager, error) { cond := models.Posts.BuildConditionWithKey(query.Keyword) - return models.Posts.List(ctx.Context(), pagination, cond) + pager, err := models.Posts.List(ctx.Context(), pagination, cond) + if err != nil { + return nil, err + } + + postIds := lo.Map(pager.Items.([]model.Posts), func(item model.Posts, _ int) int64 { + return item.ID + }) + if len(postIds) > 0 { + postCntMap, err := models.Posts.BoughtStatistics(ctx.Context(), postIds) + if err != nil { + return pager, err + } + + items := lo.Map(pager.Items.([]model.Posts), func(item model.Posts, _ int) PostItem { + cnt := int64(0) + if v, ok := postCntMap[item.ID]; ok { + cnt = v + } + + return PostItem{Posts: &item, BoughtCount: cnt} + }) + + pager.Items = items + } + return pager, err } type PostForm struct { @@ -142,7 +167,8 @@ func (ctl *posts) Delete(ctx fiber.Ctx, id int64) error { type PostItem struct { *model.Posts - Medias []*model.Medias `json:"medias"` + Medias []*model.Medias `json:"medias"` + BoughtCount int64 `json:"bought_count"` } // Show posts by id @@ -165,3 +191,22 @@ func (ctl *posts) Show(ctx fiber.Ctx, id int64) (*PostItem, error) { Medias: medias, }, nil } + +// SendTo +// @Router /v1/admin/posts/:id/send-to/:userId [post] +// @Bind id path +// @Bind userId path +func (ctl *posts) SendTo(ctx fiber.Ctx, id, userId int64) error { + if _, err := models.Posts.GetByID(ctx.Context(), id); err != nil { + return err + } + + if _, err := models.Users.GetByID(ctx.Context(), userId); err != nil { + return err + } + + if err := models.Posts.SendTo(ctx.Context(), id, userId); err != nil { + return err + } + return nil +} diff --git a/backend/app/http/admin/routes.gen.go b/backend/app/http/admin/routes.gen.go index cdc5b81..d9fa8c8 100644 --- a/backend/app/http/admin/routes.gen.go +++ b/backend/app/http/admin/routes.gen.go @@ -50,6 +50,11 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), )) + router.Delete("/v1/admin/medias/:id", Func1( + r.medias.Delete, + PathParam[int64]("id"), + )) + // 注册路由组: orders router.Get("/v1/admin/orders", DataFunc2( r.orders.List, @@ -85,6 +90,12 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), )) + router.Post("/v1/admin/posts/:id/send-to/:userId", Func2( + r.posts.SendTo, + PathParam[int64]("id"), + PathParam[int64]("userId"), + )) + // 注册路由组: uploads router.Get("/v1/admin/uploads/pre-uploaded-check/:md5.:ext", DataFunc3( r.uploads.PreUploadCheck, diff --git a/backend/app/http/posts.go b/backend/app/http/posts.go index 9014c9b..7e49553 100644 --- a/backend/app/http/posts.go +++ b/backend/app/http/posts.go @@ -14,6 +14,7 @@ import ( "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/log" "github.com/pkg/errors" + "github.com/samber/lo" ) type ListQuery struct { @@ -25,6 +26,11 @@ type posts struct { wepay *wepay.Client } +type PostItem struct { + model.Posts + BoughtCount int64 `json:"bought_count"` +} + // List posts // @Router /api/posts [get] // @Bind pagination query @@ -32,11 +38,34 @@ type posts struct { // @Bind user local func (ctl *posts) List(ctx fiber.Ctx, pagination *requests.Pagination, query *ListQuery, user *model.Users) (*requests.Pager, error) { cond := models.Posts.BuildConditionWithKey(query.Keyword) - return models.Posts.List(ctx.Context(), pagination, cond, func(item model.Posts) model.Posts { + pager, err := models.Posts.List(ctx.Context(), pagination, cond, func(item model.Posts) model.Posts { item.Assets = fields.ToJson([]fields.MediaAsset{}) item.Content = "" return item }) + + postIds := lo.Map(pager.Items.([]model.Posts), func(item model.Posts, _ int) int64 { + return item.ID + }) + if len(postIds) > 0 { + postCntMap, err := models.Posts.BoughtStatistics(ctx.Context(), postIds) + if err != nil { + return pager, err + } + + items := lo.Map(pager.Items.([]model.Posts), func(item model.Posts, _ int) PostItem { + cnt := int64(0) + if v, ok := postCntMap[item.ID]; ok { + cnt = v + } + + return PostItem{Posts: item, BoughtCount: cnt} + }) + + pager.Items = items + } + + return pager, err } // Show diff --git a/backend/app/models/medias.go b/backend/app/models/medias.go index 3c22053..8756c9f 100644 --- a/backend/app/models/medias.go +++ b/backend/app/models/medias.go @@ -198,3 +198,20 @@ func (m *mediasModel) GetByID(ctx context.Context, id int64) (*model.Medias, err return &media, nil } + +// Delete +func (m *mediasModel) Delete(ctx context.Context, id int64) error { + tbl := table.Medias + stmt := tbl. + DELETE(). + WHERE(tbl.ID.EQ(Int64(id))) + m.log.Infof("sql: %s", stmt.DebugSql()) + + if _, err := stmt.ExecContext(ctx, db); err != nil { + m.log.Errorf("error deleting media item: %v", err) + return err + } + + m.log.Infof("media item deleted successfully") + return nil +} diff --git a/backend/app/models/posts.go b/backend/app/models/posts.go index 09339e9..b0344e7 100644 --- a/backend/app/models/posts.go +++ b/backend/app/models/posts.go @@ -233,3 +233,56 @@ func (m *postsModel) DeleteByID(ctx context.Context, id int64) error { } return nil } + +// SendTo +func (m *postsModel) SendTo(ctx context.Context, userId, postId int64) error { + // add record to user_posts + tbl := table.UserPosts + stmt := tbl.INSERT(tbl.MutableColumns).MODEL(model.UserPosts{ + UserID: userId, + PostID: postId, + }) + m.log.Infof("sql: %s", stmt.DebugSql()) + if _, err := stmt.ExecContext(ctx, db); err != nil { + m.log.Errorf("error sending post to user: %v", err) + return err + } + return nil +} + +// PostBoughtStatistics 获取指定文件 ID 的购买次数 +func (m *postsModel) BoughtStatistics(ctx context.Context, postIds []int64) (map[int64]int64, error) { + tbl := table.UserPosts + + // select count(user_id), post_id from user_posts up where post_id in (1, 2,3,4,5,6,7,8,9,10) group by post_id + stmt := tbl. + SELECT( + COUNT(tbl.UserID).AS("cnt"), + tbl.PostID.AS("post_id"), + ). + WHERE( + tbl.PostID.IN(lo.Map(postIds, func(id int64, _ int) Expression { return Int64(id) })...), + ). + GROUP_BY( + tbl.PostID, + ) + m.log.Infof("sql: %s", stmt.DebugSql()) + + var result []struct { + Cnt int64 + PostId int64 + } + + if err := stmt.QueryContext(ctx, db, &result); err != nil { + m.log.Errorf("error getting post bought statistics: %v", err) + return nil, err + } + + // convert to map + resultMap := make(map[int64]int64) + for _, item := range result { + resultMap[item.PostId] = item.Cnt + } + + return resultMap, nil +} diff --git a/backend/providers/ali/config.go b/backend/providers/ali/config.go index 35de32a..f4b8b1d 100644 --- a/backend/providers/ali/config.go +++ b/backend/providers/ali/config.go @@ -36,13 +36,20 @@ func Provide(opts ...opt.Option) error { return container.Container.Provide(func() (*OSSClient, error) { cred := credentials.NewStaticCredentialsProvider(config.AccessKeyId, config.AccessKeySecret) + cfg := oss.LoadDefaultConfig(). + WithCredentialsProvider(cred). + WithRegion(config.Region). + WithUseCName(true). + WithEndpoint(*config.Host) + + cfgInternal := oss.LoadDefaultConfig(). WithCredentialsProvider(cred). WithRegion(config.Region) return &OSSClient{ - client: oss.NewClient(cfg.WithUseCName(true).WithEndpoint(*config.Host)), - internalClient: oss.NewClient(cfg.WithUseInternalEndpoint(true)), + client: oss.NewClient(cfg), + internalClient: oss.NewClient(cfgInternal), config: &config, }, nil }, o.DiOptions()...) diff --git a/backend/providers/ali/oss_client.go b/backend/providers/ali/oss_client.go index 47f1116..35ae881 100644 --- a/backend/providers/ali/oss_client.go +++ b/backend/providers/ali/oss_client.go @@ -53,3 +53,17 @@ func (c *OSSClient) GetSignedUrl(ctx context.Context, path string) (string, erro } return preSign.URL, nil } + +// Delete +func (c *OSSClient) Delete(ctx context.Context, path string) error { + request := &oss.DeleteObjectRequest{ + Bucket: oss.Ptr(c.config.Bucket), + Key: oss.Ptr(path), + } + + _, err := c.internalClient.DeleteObject(ctx, request) + if err != nil { + return err + } + return nil +} diff --git a/backend/test.http b/backend/test.http index 11aa9d9..e9ca1fb 100644 --- a/backend/test.http +++ b/backend/test.http @@ -39,8 +39,9 @@ GET {{host}}/v1/admin/medias HTTP/1.1 Content-Type: application/json ### get posts -GET {{host}}/v1/admin/posts HTTP/1.1 +GET {{host}}/v1/admin/posts?page=10 HTTP/1.1 Content-Type: application/json +Authorization: {{token}} ### get posts with keyword GET {{host}}/v1/admin/posts?page=1&limit=10&keyword=99123 HTTP/1.1 diff --git a/frontend/admin/src/api/postService.js b/frontend/admin/src/api/postService.js index 9bb009f..d048da6 100644 --- a/frontend/admin/src/api/postService.js +++ b/frontend/admin/src/api/postService.js @@ -23,4 +23,7 @@ export const postService = { deletePost(id) { return httpClient.delete(`/admin/posts/${id}`); }, + sendTo(id, userId) { + return httpClient.post(`/admin/posts/${id}/send-to/${userId}`); + }, } \ No newline at end of file diff --git a/frontend/admin/src/api/userService.js b/frontend/admin/src/api/userService.js index 346bef7..10bb024 100644 --- a/frontend/admin/src/api/userService.js +++ b/frontend/admin/src/api/userService.js @@ -10,6 +10,9 @@ export const userService = { } }); }, + searchUser(id) { + return httpClient.get(`/admin/users/${id}`); + }, getUser(id) { return httpClient.get(`/admin/users/${id}`); }, diff --git a/frontend/admin/src/pages/MediaPage.vue b/frontend/admin/src/pages/MediaPage.vue index a34c345..e7b9d8c 100644 --- a/frontend/admin/src/pages/MediaPage.vue +++ b/frontend/admin/src/pages/MediaPage.vue @@ -7,6 +7,7 @@ import { InputText } from "primevue"; import Badge from "primevue/badge"; import Button from "primevue/button"; import Column from "primevue/column"; +import ConfirmDialog from 'primevue/confirmdialog'; import DataTable from "primevue/datatable"; import Dialog from 'primevue/dialog'; import Dropdown from "primevue/dropdown"; @@ -172,10 +173,45 @@ const previewFile = (file) => { previewDialogVisible.value = true; }; + +// Add delete related methods +const confirmDelete = (file) => { + confirm.require({ + message: `确定要删除文件 "${file.name}" 吗?`, + header: '确认删除', + icon: 'pi pi-exclamation-triangle', + acceptClass: 'p-button-danger', + accept: () => handleDelete(file), + reject: () => { }, + acceptLabel: '删除', + rejectLabel: '取消' + }); +}; + +const handleDelete = async (file) => { + try { + await mediaService.delete(file.id); + toast.add({ + severity: 'success', + summary: '成功', + detail: '文件已删除', + life: 3000 + }); + fetchMediaFiles(); + } catch (error) { + toast.add({ + severity: 'error', + summary: '错误', + detail: '删除文件失败', + life: 3000 + }); + } +};