diff --git a/.DS_Store b/.DS_Store index de5d48e..ea52618 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/api/v1/plant/callback.go b/api/v1/plant/callback.go new file mode 100644 index 0000000..c478237 --- /dev/null +++ b/api/v1/plant/callback.go @@ -0,0 +1,30 @@ +package plant + +import ( + "sundynix-go/global" + "sundynix-go/model/commom/response" + plantRes "sundynix-go/model/plant/response" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type CallbackApi struct{} + +// MediaCheckCallback 微信内容安全回调接口 +func (a *CallbackApi) MediaCheckCallback(c *gin.Context) { + var cb plantRes.WeChatCheckResultCallback + if err := c.ShouldBindJSON(&cb); err != nil { + global.Logger.Error("解析微信回调JSON失败", zap.Error(err)) + response.FailWithMsg("解析失败", c) + return + } + + if err := callbackService.HandleMediaCheckCallback(cb); err != nil { + global.Logger.Error("处理微信回调失败", zap.Error(err)) + response.FailWithMsg("处理失败", c) + return + } + + response.OkWithMsg("success", c) +} diff --git a/api/v1/plant/enter.go b/api/v1/plant/enter.go index e56cc52..2f8f8b2 100644 --- a/api/v1/plant/enter.go +++ b/api/v1/plant/enter.go @@ -13,6 +13,7 @@ type ApiGroup struct { UserProfileApi BadgeConfigApi CallbackApi + ExchangeApi } var ( @@ -26,4 +27,5 @@ var ( userProfileService = service.GroupApp.PlantServiceGroup.UserProfileService badgeConfigService = service.GroupApp.PlantServiceGroup.BadgeConfigService callbackService = service.GroupApp.PlantServiceGroup.CallbackService + exchangeService = service.GroupApp.PlantServiceGroup.ExchangeService ) diff --git a/api/v1/plant/exchange.go b/api/v1/plant/exchange.go new file mode 100644 index 0000000..fed8f1e --- /dev/null +++ b/api/v1/plant/exchange.go @@ -0,0 +1,275 @@ +package plant + +import ( + "sundynix-go/global" + "sundynix-go/model/commom/response" + "sundynix-go/model/plant/request" + "sundynix-go/utils/auth" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type ExchangeApi struct{} + +// ==================== 管理端 API ==================== + +// CreateItem 创建兑换商品 +// @Tags 兑换中心-管理 +// @Summary 创建兑换商品 +// @Security BearerAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ExchangeItemCreateReq true "创建兑换商品" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}" +// @Router /exchange/item/create [post] +func (a *ExchangeApi) CreateItem(c *gin.Context) { + var req request.ExchangeItemCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + if err := exchangeService.CreateItem(req); err != nil { + global.Logger.Error("创建兑换商品失败", zap.Error(err)) + response.FailWithMsg("创建失败", c) + return + } + response.OkWithMsg("创建成功", c) +} + +// UpdateItem 更新兑换商品 +// @Tags 兑换中心-管理 +// @Summary 更新兑换商品 +// @Security BearerAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ExchangeItemUpdateReq true "更新兑换商品" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"更新成功"}" +// @Router /exchange/item/update [post] +func (a *ExchangeApi) UpdateItem(c *gin.Context) { + var req request.ExchangeItemUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + if err := exchangeService.UpdateItem(req); err != nil { + global.Logger.Error("更新兑换商品失败", zap.Error(err)) + response.FailWithMsg("更新失败", c) + return + } + response.OkWithMsg("更新成功", c) +} + +// DeleteItem 删除兑换商品 +// @Tags 兑换中心-管理 +// @Summary 删除兑换商品 +// @Security BearerAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ExchangeItemCreateReq true "删除兑换商品" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}" +// @Router /exchange/item/delete [post] +func (a *ExchangeApi) DeleteItem(c *gin.Context) { + var req struct { + Id string `json:"id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + if err := exchangeService.DeleteItem(req.Id); err != nil { + global.Logger.Error("删除兑换商品失败", zap.Error(err)) + response.FailWithMsg("删除失败", c) + return + } + response.OkWithMsg("删除成功", c) +} + +// AdminItemList 管理端商品列表 +// @Tags 兑换中心-管理 +// @Summary 管理端商品列表 +// @Security BearerAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ExchangeItemListReq true "分页获取商品列表" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}" +// @Router /exchange/item/list [post] +func (a *ExchangeApi) AdminItemList(c *gin.Context) { + var req request.ExchangeItemListReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + list, total, err := exchangeService.AdminItemList(req) + if err != nil { + global.Logger.Error("获取商品列表失败", zap.Error(err)) + response.FailWithMsg("获取失败", c) + return + } + response.OkWithData(response.PageResult{ + List: list, + Total: total, + Page: req.Current, + PageSize: req.PageSize, + }, c) +} + +// AdminOrderList 管理端订单列表 +// @Tags 兑换中心-管理 +// @Summary 管理端订单列表 +// @Security BearerAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ExchangeOrderListReq true "分页获取订单列表" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}" +// @Router /exchange/order/list [post] +func (a *ExchangeApi) AdminOrderList(c *gin.Context) { + var req request.ExchangeOrderListReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + list, total, err := exchangeService.AdminOrderList(req) + if err != nil { + global.Logger.Error("获取订单列表失败", zap.Error(err)) + response.FailWithMsg("获取失败", c) + return + } + response.OkWithData(response.PageResult{ + List: list, + Total: total, + Page: req.Current, + PageSize: req.PageSize, + }, c) +} + +// UpdateOrderStatus 更新订单状态 +// @Tags 兑换中心-管理 +// @Summary 更新订单状态 +// @Security BearerAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ExchangeOrderUpdateReq true "更新订单状态" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"更新成功"}" +// @Router /exchange/order/update [post] +func (a *ExchangeApi) UpdateOrderStatus(c *gin.Context) { + var req request.ExchangeOrderUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + if err := exchangeService.UpdateOrderStatus(req); err != nil { + global.Logger.Error("更新订单状态失败", zap.Error(err)) + response.FailWithMsg(err.Error(), c) + return + } + response.OkWithMsg("更新成功", c) +} + +// ==================== 用户端 API ==================== + +// UserItemList 用户端商品列表 +// @Tags 兑换中心-用户 +// @Summary 用户端商品列表 +// @Security BearerAuth +// @Produce application/json +// @Param current query int false "页码" +// @Param pageSize query int false "每页大小" +// @Param type query string false "商品类型" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}" +// @Router /exchange/list [get] +func (a *ExchangeApi) UserItemList(c *gin.Context) { + var req request.ExchangeItemListReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + list, total, err := exchangeService.UserItemList(req) + if err != nil { + global.Logger.Error("获取商品列表失败", zap.Error(err)) + response.FailWithMsg("获取失败", c) + return + } + response.OkWithData(response.PageResult{ + List: list, + Total: total, + Page: req.Current, + PageSize: req.PageSize, + }, c) +} + +// UserItemDetail 商品详情 +// @Tags 兑换中心-用户 +// @Summary 商品详情 +// @Security BearerAuth +// @Produce application/json +// @Param id query string true "商品ID" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}" +// @Router /exchange/detail [get] +func (a *ExchangeApi) UserItemDetail(c *gin.Context) { + itemId := c.Query("id") + if itemId == "" { + response.FailWithMsg("参数错误", c) + return + } + item, err := exchangeService.UserItemDetail(itemId) + if err != nil { + response.FailWithMsg("商品不存在", c) + return + } + response.OkWithData(item, c) +} + +// UserExchange 用户发起兑换 +// @Tags 兑换中心-用户 +// @Summary 发起兑换 +// @Security BearerAuth +// @accept application/json +// @Produce application/json +// @Param data body request.ExchangeReq true "兑换请求" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"兑换成功"}" +// @Router /exchange/redeem [post] +func (a *ExchangeApi) UserExchange(c *gin.Context) { + var req request.ExchangeReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + userId := auth.GetUserId(c) + if err := exchangeService.UserExchange(req, userId); err != nil { + response.FailWithMsg(err.Error(), c) + return + } + response.OkWithMsg("兑换成功", c) +} + +// UserOrderList 用户订单列表 +// @Tags 兑换中心-用户 +// @Summary 用户兑换记录 +// @Security BearerAuth +// @Produce application/json +// @Param current query int false "页码" +// @Param pageSize query int false "每页大小" +// @Param status query int false "订单状态" +// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}" +// @Router /exchange/orders [get] +func (a *ExchangeApi) UserOrderList(c *gin.Context) { + var req request.ExchangeOrderListReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + userId := auth.GetUserId(c) + list, total, err := exchangeService.UserOrderList(req, userId) + if err != nil { + global.Logger.Error("获取订单列表失败", zap.Error(err)) + response.FailWithMsg("获取失败", c) + return + } + response.OkWithData(response.PageResult{ + List: list, + Total: total, + Page: req.Current, + PageSize: req.PageSize, + }, c) +} diff --git a/initialize/gorm.go b/initialize/gorm.go index 8b88af7..f6bb3cb 100644 --- a/initialize/gorm.go +++ b/initialize/gorm.go @@ -39,19 +39,19 @@ func MigrateTable() { system.SysOperationRecord{}, system.Oss{}, - plant.MyPlant{}, //我的植物 - plant.CarePlan{}, //植物养护计划 - plant.CareTask{}, //植物养护任务 - plant.CareRecord{}, //植物养护记录 - plant.GrowthRecord{}, //植物成长记录 - plant.MediaCheckResult{}, //媒体安全检测结果 - plant.Topic{}, //帖子话题 - plant.Post{}, //帖子 - plant.PostLike{}, //帖子点赞 - plant.PostComment{}, //帖子评论 - plant.Class{}, //百科分类 - plant.Wiki{}, //百科植物 - plant.ClassifyRecord{}, //植物识别记录 + plant.MyPlant{}, //我的植物 + plant.CarePlan{}, //植物养护计划 + plant.CareTask{}, //植物养护任务 + plant.CareRecord{}, //植物养护记录 + plant.GrowthRecord{}, //植物成长记录 + //plant.MediaCheckResult{}, //媒体安全检测结果 + plant.Topic{}, //帖子话题 + plant.Post{}, //帖子 + plant.PostLike{}, //帖子点赞 + plant.PostComment{}, //帖子评论 + plant.Class{}, //百科分类 + plant.Wiki{}, //百科植物 + plant.ClassifyRecord{}, //植物识别记录 plant.LevelConfig{}, //等级配置 plant.BadgeConfig{}, //徽章配置 @@ -59,6 +59,8 @@ func MigrateTable() { plant.UserBadge{}, //用户徽章 plant.UserStar{}, //用户收藏 + plant.ExchangeItem{}, //兑换商品 + plant.ExchangeOrder{}, //兑换订单 ) if err != nil { global.Logger.Error("Migrate table failed,err:", zap.Error(err)) diff --git a/initialize/router.go b/initialize/router.go index 7945ffd..c1052b7 100644 --- a/initialize/router.go +++ b/initialize/router.go @@ -59,6 +59,7 @@ func Routers() { plantGroup.InitLevelConfigRouter(NeedAuthGroup) //等级配置 plantGroup.InitBadgeConfigRouter(NeedAuthGroup) //徽章配置 plantGroup.InitUserProfileRouter(NeedAuthGroup) //用户资料 + plantGroup.InitExchangeRouter(NeedAuthGroup) //兑换中心 } diff --git a/model/plant/exchange_item.go b/model/plant/exchange_item.go new file mode 100644 index 0000000..bc997f8 --- /dev/null +++ b/model/plant/exchange_item.go @@ -0,0 +1,28 @@ +package plant + +import ( + "sundynix-go/global" + "sundynix-go/model/system" + "time" +) + +// ExchangeItem 兑换商品 +type ExchangeItem struct { + global.BaseModel + Name string `json:"name" gorm:"size:100;not null;column:name"` // 商品名称 + Description string `json:"description" gorm:"type:text;column:description"` // 商品描述 + ImageId string `json:"imageId" gorm:"size:50;column:image_id"` // 商品图片ID + Type string `json:"type" gorm:"size:20;not null;default:PHYSICAL;column:type"` // 商品类型: PHYSICAL/VIRTUAL/COUPON + CostSunlight int64 `json:"costSunlight" gorm:"not null;default:0;column:cost_sunlight"` // 消耗阳光值 + Stock int `json:"stock" gorm:"not null;default:-1;column:stock"` // 库存 -1无限 + LimitPerUser int `json:"limitPerUser" gorm:"not null;default:0;column:limit_per_user"` // 每人限兑次数 0不限 + Status int `json:"status" gorm:"not null;default:1;column:status"` // 1上架 2下架 + Sort int `json:"sort" gorm:"not null;default:0;column:sort"` // 排序 + StartTime *time.Time `json:"startTime" gorm:"column:start_time"` // 上架时间 + EndTime *time.Time `json:"endTime" gorm:"column:end_time"` // 下架时间 + Image *system.Oss `json:"image" gorm:"foreignKey:ImageId"` // 商品图片关联 +} + +func (ExchangeItem) TableName() string { + return "sundynix_exchange_item" +} diff --git a/model/plant/exchange_order.go b/model/plant/exchange_order.go new file mode 100644 index 0000000..b46e4e2 --- /dev/null +++ b/model/plant/exchange_order.go @@ -0,0 +1,38 @@ +package plant + +import ( + "sundynix-go/global" + "time" +) + +// ExchangeOrder 兑换订单 +type ExchangeOrder struct { + global.BaseModel + UserId string `json:"userId" gorm:"index;size:50;not null;column:user_id"` // 用户ID + ItemId string `json:"itemId" gorm:"index;size:50;not null;column:item_id"` // 商品ID + ItemName string `json:"itemName" gorm:"size:100;column:item_name"` // 商品名称(冗余快照) + CostSunlight int64 `json:"costSunlight" gorm:"not null;default:0;column:cost_sunlight"` // 消耗阳光值 + Quantity int `json:"quantity" gorm:"not null;default:1;column:quantity"` // 数量 + Status int `json:"status" gorm:"not null;default:1;column:status"` // 1待处理 2处理中 3已发货 4已完成 5已取消 + ItemType string `json:"itemType" gorm:"size:20;column:item_type"` // 商品类型快照 + RecipientName string `json:"recipientName" gorm:"size:50;column:recipient_name"` // 收货人姓名 + Phone string `json:"phone" gorm:"size:20;column:phone"` // 联系电话 + Address string `json:"address" gorm:"size:255;column:address"` // 收货地址 + TrackingNo string `json:"trackingNo" gorm:"size:100;column:tracking_no"` // 快递单号 + Remark string `json:"remark" gorm:"size:255;column:remark"` // 备注 + CompletedAt *time.Time `json:"completedAt" gorm:"column:completed_at"` // 完成时间 + Item *ExchangeItem `json:"item" gorm:"foreignKey:ItemId"` // 商品关联 +} + +func (ExchangeOrder) TableName() string { + return "sundynix_exchange_order" +} + +// 订单状态常量 +const ( + OrderStatusPending = 1 // 待处理 + OrderStatusProcessing = 2 // 处理中 + OrderStatusShipped = 3 // 已发货 + OrderStatusCompleted = 4 // 已完成 + OrderStatusCancelled = 5 // 已取消 +) diff --git a/model/plant/media_check.go b/model/plant/media_check.go new file mode 100644 index 0000000..224fa76 --- /dev/null +++ b/model/plant/media_check.go @@ -0,0 +1,22 @@ +package plant + +import ( + "sundynix-go/global" +) + +// MediaCheckResult 媒体安全检测结果 +// 对应微信 media_check_async 接口的返回结果 +type MediaCheckResult struct { + global.BaseModel + TraceId string `json:"traceId" gorm:"column:trace_id;size:100;uniqueIndex;comment:微信返回的唯一任务id"` + PostId string `json:"postId" gorm:"column:post_id;size:50;index;comment:关联的帖子id"` + OssId string `json:"ossId" gorm:"column:oss_id;size:50;comment:关联的oss文件id"` + UserId string `json:"userId" gorm:"column:user_id;size:50;comment:提交检测的用户id"` + Status int `json:"status" gorm:"column:status;default:0;comment:检测状态 0:检测中 1:通过 2:违规"` + Type int `json:"type" gorm:"column:type;comment:媒体类型 1:音频 2:图片"` + ErrMsg string `json:"errMsg" gorm:"column:err_msg;size:255;comment:错误信息"` +} + +func (MediaCheckResult) TableName() string { + return "sundynix_media_check_result" +} diff --git a/model/plant/my_plant_care_plan.go b/model/plant/my_plant_care_plan.go index 41806c6..c83cab7 100644 --- a/model/plant/my_plant_care_plan.go +++ b/model/plant/my_plant_care_plan.go @@ -2,9 +2,6 @@ package plant import ( "sundynix-go/global" - "sundynix-go/utils/timer" - - "gorm.io/gorm" ) // CarePlan 养护计划 @@ -19,28 +16,28 @@ type CarePlan struct { } // AfterUpdate 钩子函数 修改计划后重新生成任务 -func (p *CarePlan) AfterUpdate(tx *gorm.DB) error { - //1.删除旧任务 - err := tx.Where("plan_id = ?", p.Id).Unscoped().Delete(&CareTask{}).Error - if err != nil { - return err - } - //2.创建新任务 - today := timer.GetZeroTime() - dueDate := today.AddDate(0, 0, p.Period) - task := CareTask{ - UserId: p.UserId, - PlantId: p.Id, - PlanId: p.Id, - Name: p.Name, - Icon: p.Icon, - DueDate: dueDate, - Status: 1, - } - err = tx.Create(&task).Error - if err != nil { - return err - } - return nil +// func (p *CarePlan) AfterUpdate(tx *gorm.DB) error { +// //1.删除旧任务 +// err := tx.Where("plan_id = ?", p.Id).Unscoped().Delete(&CareTask{}).Error +// if err != nil { +// return err +// } +// //2.创建新任务 +// today := timer.GetZeroTime() +// dueDate := today.AddDate(0, 0, p.Period) +// task := CareTask{ +// UserId: p.UserId, +// PlantId: p.Id, +// PlanId: p.Id, +// Name: p.Name, +// Icon: p.Icon, +// DueDate: dueDate, +// Status: 1, +// } +// err = tx.Create(&task).Error +// if err != nil { +// return err +// } +// return nil -} +// } diff --git a/model/plant/request/exchange.go b/model/plant/request/exchange.go new file mode 100644 index 0000000..b62dff9 --- /dev/null +++ b/model/plant/request/exchange.go @@ -0,0 +1,64 @@ +package request + +import common "sundynix-go/model/commom/request" + +// ExchangeItemCreateReq 创建兑换商品请求 +type ExchangeItemCreateReq struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + ImageId string `json:"imageId"` + Type string `json:"type" binding:"required"` // PHYSICAL / VIRTUAL / COUPON + CostSunlight int64 `json:"costSunlight" binding:"required,min=1"` + Stock int `json:"stock"` // -1 无限 + LimitPerUser int `json:"limitPerUser"` // 0 不限 + Sort int `json:"sort"` + StartTime string `json:"startTime"` // 可选 + EndTime string `json:"endTime"` // 可选 +} + +// ExchangeItemUpdateReq 更新兑换商品请求 +type ExchangeItemUpdateReq struct { + Id string `json:"id" binding:"required"` + Name string `json:"name"` + Description string `json:"description"` + ImageId string `json:"imageId"` + Type string `json:"type"` + CostSunlight int64 `json:"costSunlight"` + Stock int `json:"stock"` + LimitPerUser int `json:"limitPerUser"` + Status int `json:"status"` // 1上架 2下架 + Sort int `json:"sort"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` +} + +// ExchangeItemListReq 商品列表查询请求 +type ExchangeItemListReq struct { + common.PageInfo + Type string `json:"type" form:"type"` // 按类型筛选 + Status int `json:"status" form:"status"` // 按状态筛选 +} + +// ExchangeReq 用户兑换请求 +type ExchangeReq struct { + ItemId string `json:"itemId" binding:"required"` + Quantity int `json:"quantity"` // 默认1 + RecipientName string `json:"recipientName"` + Phone string `json:"phone"` + Address string `json:"address"` +} + +// ExchangeOrderListReq 订单列表查询请求 +type ExchangeOrderListReq struct { + common.PageInfo + Status int `json:"status" form:"status"` // 按状态筛选 + UserId string `json:"userId" form:"userId"` // 管理端按用户筛选 +} + +// ExchangeOrderUpdateReq 更新订单状态请求 (管理端) +type ExchangeOrderUpdateReq struct { + Id string `json:"id" binding:"required"` + Status int `json:"status" binding:"required"` // 目标状态 + TrackingNo string `json:"trackingNo"` // 快递单号(发货时) + Remark string `json:"remark"` +} diff --git a/model/plant/wiki.go b/model/plant/wiki.go index 20bd3fc..6a59831 100644 --- a/model/plant/wiki.go +++ b/model/plant/wiki.go @@ -12,13 +12,13 @@ type Wiki struct { Name string `json:"name" form:"name" gorm:"column:name;size:50;comment:名称"` LatinName string `json:"latinName" form:"latinName" gorm:"size:100;column:latin_name;comment:拉丁名"` Aliases string `json:"aliases" form:"aliases" gorm:"size:100;column:aliases;comment:别名(逗号分隔)"` - DistributionArea string `json:"distributionArea" form:"distributionArea" gorm:"size:100;column:distribution_area;comment:分布区域"` //分布区域 + DistributionArea string `json:"distributionArea" form:"distributionArea" gorm:"type:text;;column:distribution_area;comment:分布区域"` //分布区域 //科学分类 Genus string `json:"genus" form:"genus" gorm:"size:20;column:genus;comment:科属"` // 属 Difficulty int `json:"difficulty" form:"difficulty" gorm:"column:difficulty;comment:种植难度"` //种植难度 1-5级 //形态特征 - LifeCycle string `json:"lifeCycle" form:"lifeCycle" gorm:"size:20;column:life_cycle;comment:生命周期"` // 生命周期 一年生 二年生 多年生等 - GrowthHabit string `json:"growthHabit" form:"growthHabit" gorm:"size:200;column:growth_habit;comment:成长习性"` // 生长习性 + LifeCycle string `json:"lifeCycle" form:"lifeCycle" gorm:"type:text;column:life_cycle;comment:生命周期"` // 生命周期 一年生 二年生 多年生等 + GrowthHabit string `json:"growthHabit" form:"growthHabit" gorm:"type:text;column:growth_habit;comment:成长习性"` // 生长习性 ReproductionMethod string `json:"reproductionMethod" form:"reproductionMethod" gorm:"size:200;column:reproduction_method;comment:繁殖方法"` //繁殖方法 PestsDiseases string `json:"pestsDiseases" form:"pestsDiseases" gorm:"size:200;column:pests_diseases;comment:病虫害"` //光照 diff --git a/pkg/.DS_Store b/pkg/.DS_Store new file mode 100644 index 0000000..ba16104 Binary files /dev/null and b/pkg/.DS_Store differ diff --git a/router/plant/callback_router.go b/router/plant/callback_router.go new file mode 100644 index 0000000..b692b3e --- /dev/null +++ b/router/plant/callback_router.go @@ -0,0 +1,14 @@ +package plant + +import ( + "github.com/gin-gonic/gin" +) + +type CallbackRouter struct{} + +func (s *CallbackRouter) InitCallbackRouter(Router *gin.RouterGroup) { + callbackRouter := Router.Group("callback") + { + callbackRouter.POST("mediaCheck", callbackApi.MediaCheckCallback) // 接收微信媒体检测回调 + } +} diff --git a/router/plant/enter.go b/router/plant/enter.go index d0263ef..5890a00 100644 --- a/router/plant/enter.go +++ b/router/plant/enter.go @@ -13,6 +13,7 @@ type RouterGroup struct { BadgeConfigRouter UserProfileRouter CallbackRouter + ExchangeRouter } // 初始化路由 @@ -27,4 +28,5 @@ var ( userProfileApi = v1.ApiGroupApp.PlantApiGroup.UserProfileApi badgeConfigApi = v1.ApiGroupApp.PlantApiGroup.BadgeConfigApi callbackApi = v1.ApiGroupApp.PlantApiGroup.CallbackApi + exchangeApi = v1.ApiGroupApp.PlantApiGroup.ExchangeApi ) diff --git a/router/plant/exchange_router.go b/router/plant/exchange_router.go new file mode 100644 index 0000000..f665fb0 --- /dev/null +++ b/router/plant/exchange_router.go @@ -0,0 +1,27 @@ +package plant + +import "github.com/gin-gonic/gin" + +type ExchangeRouter struct{} + +func (r *ExchangeRouter) InitExchangeRouter(Router *gin.RouterGroup) { + // ========== 用户端路由 ========== + userRouter := Router.Group("exchange") + { + userRouter.GET("list", exchangeApi.UserItemList) // 商品列表 + userRouter.GET("detail", exchangeApi.UserItemDetail) // 商品详情 + userRouter.POST("redeem", exchangeApi.UserExchange) // 发起兑换 + userRouter.GET("orders", exchangeApi.UserOrderList) // 我的兑换记录 + } + + // ========== 管理端路由 ========== + adminRouter := Router.Group("exchange") + { + adminRouter.POST("item/create", exchangeApi.CreateItem) // 创建商品 + adminRouter.POST("item/update", exchangeApi.UpdateItem) // 更新商品 + adminRouter.POST("item/delete", exchangeApi.DeleteItem) // 删除商品 + adminRouter.POST("item/list", exchangeApi.AdminItemList) // 管理端商品列表 + adminRouter.POST("order/list", exchangeApi.AdminOrderList) // 管理端订单列表 + adminRouter.POST("order/update", exchangeApi.UpdateOrderStatus) // 更新订单状态 + } +} diff --git a/service/plant/callback.go b/service/plant/callback.go new file mode 100644 index 0000000..335f814 --- /dev/null +++ b/service/plant/callback.go @@ -0,0 +1,110 @@ +package plant + +import ( + "sundynix-go/global" + "sundynix-go/model/plant" + plantres "sundynix-go/model/plant/response" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +type CallbackService struct{} + +var CallbackServiceApp = new(CallbackService) + +// HandleMediaCheckCallback 处理媒体检测回调 +func (s *CallbackService) HandleMediaCheckCallback(cb plantres.WeChatCheckResultCallback) error { + global.Logger.Info("收到微信媒体检测回调", zap.String("traceId", cb.TraceId), zap.String("suggest", cb.Result.Suggest)) + + var checkResult plant.MediaCheckResult + err := global.DB.Where("trace_id = ?", cb.TraceId).First(&checkResult).Error + if err != nil { + global.Logger.Error("回调traceId未找到", zap.String("traceId", cb.TraceId), zap.Error(err)) + return err + } + + // 1. 更新检测结果状态 + status := 0 + if cb.Result.Suggest == "pass" { + status = 1 + } else { + status = 2 + } + + err = global.DB.Model(&checkResult).Updates(map[string]interface{}{ + "status": status, + "err_msg": cb.Result.Suggest, + }).Error + + if err != nil { + global.Logger.Error("更新检测结果失败", zap.Error(err)) + return err + } + + // 2. 根据结果处理帖子状态 + return s.updatePostStatus(checkResult.PostId) +} + +// updatePostStatus 更新帖子状态 +// 逻辑: +// 1. 如果有任意一个检测结果为违规(2) -> 帖子违规(2) +// 2. 如果所有检测结果都为通过(1) -> 帖子通过(1) +// 3. 否则保持待审核(0) +func (s *CallbackService) updatePostStatus(postId string) error { + return global.DB.Transaction(func(tx *gorm.DB) error { + var post plant.Post + if err := tx.Where("id = ?", postId).First(&post).Error; err != nil { + return err + } + + // 如果帖子已经是违规状态,无需再处理(可能之前已经由文本检测判定违规) + if post.HasReviewed == 2 { + return nil + } + + var results []plant.MediaCheckResult + if err := tx.Where("post_id = ?", postId).Find(&results).Error; err != nil { + return err + } + + hasRisky := false + allPass := true + + for _, res := range results { + if res.Status == 2 { + hasRisky = true + break + } + if res.Status != 1 { + allPass = false + } + } + + var newStatus = post.HasReviewed // 默认保持原状态 + + if hasRisky { + newStatus = 2 + // TODO: 这里可以执行额外的封禁逻辑,例如不仅标记违规,还软删除Oss关联等 + global.Logger.Warn("帖子包含违规图片,标记为违规", zap.String("postId", postId)) + } else if allPass { + // 只有当所有图片都通过,且原状态不是违规时,才标记为通过 + // 注意:这里假设文本检测已经通过(文本检测是同步的,若不通过早已设为2) + // 如果文本检测尚未完成(理论上不可能,因为是先文本后图片),这里可能会有竞态,但文本检测在发帖goroutine中是串行的。 + // 唯一需要注意的是,如果文本检测还在进行中,这里不应覆盖。 + // 但我们在PublishPost中是先改HasReviewed再发图片检查。 + // 如果文本通过,HasReviewed会被设为1? 不,根据新逻辑,PublishPost中只有无图才设为1。 + // 有图时,PublishPost中HasReviewed保持0。 + newStatus = 1 + global.Logger.Info("帖子所有图片检测通过,标记为通过", zap.String("postId", postId)) + } + + if newStatus != post.HasReviewed { + if err := tx.Model(&post).Update("has_reviewed", newStatus).Error; err != nil { + return err + } + } + + return nil + }) +} diff --git a/service/plant/enter.go b/service/plant/enter.go index ef6a384..2cca249 100644 --- a/service/plant/enter.go +++ b/service/plant/enter.go @@ -11,4 +11,5 @@ type ServiceGroup struct { BadgeConfigService UserProfileService CallbackService + ExchangeService } diff --git a/service/plant/exchange.go b/service/plant/exchange.go new file mode 100644 index 0000000..add23a5 --- /dev/null +++ b/service/plant/exchange.go @@ -0,0 +1,282 @@ +package plant + +import ( + "errors" + "sundynix-go/global" + "sundynix-go/model/plant" + plantReq "sundynix-go/model/plant/request" + "time" + + "gorm.io/gorm" +) + +type ExchangeService struct{} + +// ==================== 管理端 ==================== + +// CreateItem 创建兑换商品 +func (s *ExchangeService) CreateItem(req plantReq.ExchangeItemCreateReq) error { + item := plant.ExchangeItem{ + Name: req.Name, + Description: req.Description, + ImageId: req.ImageId, + Type: req.Type, + CostSunlight: req.CostSunlight, + Stock: req.Stock, + LimitPerUser: req.LimitPerUser, + Sort: req.Sort, + Status: 1, // 默认上架 + } + if req.StartTime != "" { + t, _ := time.Parse("2006-01-02 15:04:05", req.StartTime) + item.StartTime = &t + } + if req.EndTime != "" { + t, _ := time.Parse("2006-01-02 15:04:05", req.EndTime) + item.EndTime = &t + } + return global.DB.Create(&item).Error +} + +// UpdateItem 更新兑换商品 +func (s *ExchangeService) UpdateItem(req plantReq.ExchangeItemUpdateReq) error { + updateMap := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "image_id": req.ImageId, + "type": req.Type, + "cost_sunlight": req.CostSunlight, + "stock": req.Stock, + "limit_per_user": req.LimitPerUser, + "status": req.Status, + "sort": req.Sort, + } + if req.StartTime != "" { + t, _ := time.Parse("2006-01-02 15:04:05", req.StartTime) + updateMap["start_time"] = t + } + if req.EndTime != "" { + t, _ := time.Parse("2006-01-02 15:04:05", req.EndTime) + updateMap["end_time"] = t + } + return global.DB.Model(&plant.ExchangeItem{}).Where("id = ?", req.Id).Updates(updateMap).Error +} + +// DeleteItem 删除兑换商品 +func (s *ExchangeService) DeleteItem(id string) error { + return global.DB.Where("id = ?", id).Delete(&plant.ExchangeItem{}).Error +} + +// AdminItemList 管理端商品列表(含下架) +func (s *ExchangeService) AdminItemList(req plantReq.ExchangeItemListReq) (list []plant.ExchangeItem, total int64, err error) { + db := global.DB.Model(&plant.ExchangeItem{}).Preload("Image") + if req.Type != "" { + db = db.Where("type = ?", req.Type) + } + if req.Status != 0 { + db = db.Where("status = ?", req.Status) + } + if req.Keyword != "" { + db = db.Where("name LIKE ?", "%"+req.Keyword+"%") + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(req.Paginate()).Order("sort asc, created_at desc").Find(&list).Error + return +} + +// AdminOrderList 管理端订单列表 +func (s *ExchangeService) AdminOrderList(req plantReq.ExchangeOrderListReq) (list []plant.ExchangeOrder, total int64, err error) { + db := global.DB.Model(&plant.ExchangeOrder{}).Preload("Item", func(db *gorm.DB) *gorm.DB { + return db.Preload("Image") + }) + if req.Status != 0 { + db = db.Where("status = ?", req.Status) + } + if req.UserId != "" { + db = db.Where("user_id = ?", req.UserId) + } + if req.Keyword != "" { + db = db.Where("item_name LIKE ? OR recipient_name LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%") + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(req.Paginate()).Order("created_at desc").Find(&list).Error + return +} + +// UpdateOrderStatus 更新订单状态(管理端) +func (s *ExchangeService) UpdateOrderStatus(req plantReq.ExchangeOrderUpdateReq) error { + updateMap := map[string]interface{}{ + "status": req.Status, + } + if req.TrackingNo != "" { + updateMap["tracking_no"] = req.TrackingNo + } + if req.Remark != "" { + updateMap["remark"] = req.Remark + } + if req.Status == plant.OrderStatusCompleted { + now := time.Now() + updateMap["completed_at"] = now + } + // 如果取消订单,退还阳光值 + if req.Status == plant.OrderStatusCancelled { + return global.DB.Transaction(func(tx *gorm.DB) error { + var order plant.ExchangeOrder + if err := tx.Where("id = ?", req.Id).First(&order).Error; err != nil { + return err + } + if order.Status == plant.OrderStatusCancelled { + return errors.New("订单已取消") + } + // 退还阳光值 + if err := tx.Model(&plant.UserProfile{}).Where("user_id = ?", order.UserId). + Update("current_sunlight", gorm.Expr("current_sunlight + ?", order.CostSunlight)).Error; err != nil { + return err + } + // 恢复库存 + if err := tx.Model(&plant.ExchangeItem{}).Where("id = ? AND stock >= 0", order.ItemId). + Update("stock", gorm.Expr("stock + ?", order.Quantity)).Error; err != nil { + // 库存为-1(无限)时,WHERE条件不匹配,不影响 + } + return tx.Model(&plant.ExchangeOrder{}).Where("id = ?", req.Id).Updates(updateMap).Error + }) + } + return global.DB.Model(&plant.ExchangeOrder{}).Where("id = ?", req.Id).Updates(updateMap).Error +} + +// ==================== 用户端 ==================== + +// UserItemList 用户端商品列表(仅上架) +func (s *ExchangeService) UserItemList(req plantReq.ExchangeItemListReq) (list []plant.ExchangeItem, total int64, err error) { + db := global.DB.Model(&plant.ExchangeItem{}).Preload("Image"). + Where("status = 1") + if req.Type != "" { + db = db.Where("type = ?", req.Type) + } + if req.Keyword != "" { + db = db.Where("name LIKE ?", "%"+req.Keyword+"%") + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(req.Paginate()).Order("sort asc, created_at desc").Find(&list).Error + return +} + +// UserItemDetail 商品详情 +func (s *ExchangeService) UserItemDetail(itemId string) (plant.ExchangeItem, error) { + var item plant.ExchangeItem + err := global.DB.Preload("Image").Where("id = ? AND status = 1", itemId).First(&item).Error + return item, err +} + +// UserExchange 用户发起兑换 +func (s *ExchangeService) UserExchange(req plantReq.ExchangeReq, userId string) error { + if req.Quantity <= 0 { + req.Quantity = 1 + } + + return global.DB.Transaction(func(tx *gorm.DB) error { + // 1. 查询并锁定商品 + var item plant.ExchangeItem + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ? AND status = 1", req.ItemId).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("商品不存在或已下架") + } + return err + } + + // 2. 检查有效期 + now := time.Now() + if item.StartTime != nil && now.Before(*item.StartTime) { + return errors.New("兑换尚未开始") + } + if item.EndTime != nil && now.After(*item.EndTime) { + return errors.New("兑换已结束") + } + + // 3. 检查库存 + if item.Stock >= 0 && item.Stock < req.Quantity { + return errors.New("库存不足") + } + + // 4. 检查每人限兑 + if item.LimitPerUser > 0 { + var count int64 + tx.Model(&plant.ExchangeOrder{}).Where("user_id = ? AND item_id = ? AND status != ?", + userId, req.ItemId, plant.OrderStatusCancelled).Count(&count) + if int(count)+req.Quantity > item.LimitPerUser { + return errors.New("已达到兑换上限") + } + } + + // 5. 计算总消耗 + totalCost := item.CostSunlight * int64(req.Quantity) + + // 6. 扣减阳光值 + result := tx.Model(&plant.UserProfile{}). + Where("user_id = ? AND current_sunlight >= ?", userId, totalCost). + Update("current_sunlight", gorm.Expr("current_sunlight - ?", totalCost)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("阳光值不足") + } + + // 7. 扣减库存 + if item.Stock >= 0 { + if err := tx.Model(&plant.ExchangeItem{}).Where("id = ? AND stock >= ?", item.Id, req.Quantity). + Update("stock", gorm.Expr("stock - ?", req.Quantity)).Error; err != nil { + return err + } + } + + // 8. 创建订单 + order := plant.ExchangeOrder{ + UserId: userId, + ItemId: item.Id, + ItemName: item.Name, + CostSunlight: totalCost, + Quantity: req.Quantity, + Status: plant.OrderStatusPending, + ItemType: item.Type, + RecipientName: req.RecipientName, + Phone: req.Phone, + Address: req.Address, + } + + // 虚拟商品自动完成 + if item.Type == "VIRTUAL" { + order.Status = plant.OrderStatusCompleted + now := time.Now() + order.CompletedAt = &now + } + + return tx.Create(&order).Error + }) +} + +// UserOrderList 用户订单列表 +func (s *ExchangeService) UserOrderList(req plantReq.ExchangeOrderListReq, userId string) (list []plant.ExchangeOrder, total int64, err error) { + db := global.DB.Model(&plant.ExchangeOrder{}).Preload("Item", func(db *gorm.DB) *gorm.DB { + return db.Preload("Image") + }).Where("user_id = ?", userId) + if req.Status != 0 { + db = db.Where("status = ?", req.Status) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(req.Paginate()).Order("created_at desc").Find(&list).Error + return +} diff --git a/service/plant/my_plant.go b/service/plant/my_plant.go index 1ea62e3..8434afe 100644 --- a/service/plant/my_plant.go +++ b/service/plant/my_plant.go @@ -161,7 +161,7 @@ func (s *MyPlantService) UpdatePlant(req plantReq.UpdateMyPlant) error { if err != nil { return err } - //3.重新生成任务 CarePlans 结构体中使用钩子函数自动执行 + //3.重新生成任务 //3.1 删除旧任务 err = tx.Where("plan_id = ?", plan.Id).Unscoped().Delete(&plant.CareTask{}).Error if err != nil { @@ -170,13 +170,14 @@ func (s *MyPlantService) UpdatePlant(req plantReq.UpdateMyPlant) error { //3.2 创建新任务 dueDate := today.AddDate(0, 0, plan.Period) task := plant.CareTask{ - UserId: myPlant.UserId, - PlantId: myPlant.Id, - PlanId: plan.Id, - Name: plan.Name, - Icon: plan.Icon, - DueDate: dueDate, - Status: 1, + UserId: myPlant.UserId, + PlantId: myPlant.Id, + PlanId: plan.Id, + Name: plan.Name, + Icon: plan.Icon, + DueDate: dueDate, + Status: 1, + TargetAction: plan.TargetAction, } err = tx.Create(&task).Error if err != nil { diff --git a/service/plant/wiki.go b/service/plant/wiki.go index 0b74f80..87ac115 100644 --- a/service/plant/wiki.go +++ b/service/plant/wiki.go @@ -173,7 +173,7 @@ func (s *WikiService) WikiPage(req plantReq.WikiPage, userId string) (list inter if err != nil { return } - err = db.Limit(limit).Offset(offset).Order("created_at desc").Find(&wikis).Error + err = db.Limit(limit).Offset(offset).Order("is_hot desc,created_at desc").Find(&wikis).Error // 优化 N+1 查询 var wikiIds []string diff --git a/sundynix-plant b/sundynix-plant-go similarity index 83% rename from sundynix-plant rename to sundynix-plant-go index 60cefcc..7327598 100755 Binary files a/sundynix-plant and b/sundynix-plant-go differ diff --git a/task/care_message_send_task.go b/task/care_message_send_task.go index 9f64c32..02b4127 100644 --- a/task/care_message_send_task.go +++ b/task/care_message_send_task.go @@ -47,7 +47,10 @@ func SendCareMsg() error { // 将tasks分组,key为用户id 保证无论用户有多少植物,只给用户发送一条消息 tasksMap := make(map[string][]*plant.CareTask) for _, task := range tasks { - tasksMap[task.UserId] = append(tasksMap[task.UserId], task) + //用户id不为空再添加 + if task.UserId != "" { + tasksMap[task.UserId] = append(tasksMap[task.UserId], task) + } } for userId, cares := range tasksMap { //1.查询用户 @@ -78,9 +81,9 @@ func SendCareMsg() error { Value: time.Date(now.Year(), now.Month(), now.Day(), 8, 30, 0, 0, time.Local).Format("2006-01-02"), }, }, - //MiniProgramState: "formal", - MiniProgramState: "trial", - Lang: "zh_CN", + MiniProgramState: "formal", + //MiniProgramState: "trial", + Lang: "zh_CN", } payloadBytes, err := json.Marshal(payload) if err != nil {