feat: 植物识别

This commit is contained in:
Blizzard
2026-02-10 12:35:46 +08:00
parent e612234c91
commit 556ab6baff
24 changed files with 2745 additions and 11473 deletions
+2
View File
@@ -8,6 +8,7 @@ type ApiGroup struct {
PostApi PostApi
WikiClassApi WikiClassApi
WikiApi WikiApi
OcrApi
} }
var ( var (
@@ -16,4 +17,5 @@ var (
postService = service.GroupApp.PlantServiceGroup.PostService postService = service.GroupApp.PlantServiceGroup.PostService
wikiClassService = service.GroupApp.PlantServiceGroup.WikiClassService wikiClassService = service.GroupApp.PlantServiceGroup.WikiClassService
wikiService = service.GroupApp.PlantServiceGroup.WikiService wikiService = service.GroupApp.PlantServiceGroup.WikiService
ocrService = service.GroupApp.PlantServiceGroup.OcrService
) )
+36
View File
@@ -0,0 +1,36 @@
package plant
import (
"sundynix-go/global"
"sundynix-go/model/commom/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type OcrApi struct{}
// ClassifyPlant
// @tags 识别相关
// @Summary base64植物识别
// @Security ApiKeyAuth
// @accept multipart/form-data
// @Produce application/json
// @Param file formData file true "植物识别"
// @Success 200 {object} response.Response{msg=string} "文件OCR"
// @router /ocr/base64 [post]
func (o *OcrApi) ClassifyPlant(c *gin.Context) {
multipartFile, header, err := c.Request.FormFile("file")
if err != nil {
global.Logger.Error("接收文件失败!", zap.Error(err))
response.FailWithMsg("接收文件失败!", c)
return
}
res, err := ocrService.ClassifyPlant(multipartFile, header)
if err != nil {
global.Logger.Error("植物识别识别!", zap.Error(err))
response.FailWithMsg("植物识别失败!", c)
return
}
response.OkWithData(res, c)
}
+31
View File
@@ -70,6 +70,37 @@ func (a *PostApi) PostPage(c *gin.Context) {
}, c) }, c)
} }
// MyPost 我的发布
// @Tags 帖子
// @Summary 我的发布
// @Security BearerAuth
// @accept json
// @Produce application/json
// @Param data body plantReq.PostPage true "分页获取帖子列表"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}"
// @Router /post/myPost [post]
func (a *PostApi) MyPost(c *gin.Context) {
var req plantReq.PostPage
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMsg("请求参数错误", c)
return
}
userId := auth.GetUserId(c)
posts, total, err := postService.MyPost(req, userId)
if err != nil {
global.Logger.Error("获取帖子列表失败", zap.Error(err))
response.FailWithMsg("获取帖子列表失败", c)
return
}
response.OkWithData(response.PageResult{
List: posts,
Total: total,
Page: req.Current,
PageSize: req.PageSize,
}, c)
}
// LikePost 点赞帖子 // LikePost 点赞帖子
// @Tags 帖子 // @Tags 帖子
// @Summary 点赞帖子 // @Summary 点赞帖子
+1 -1
View File
@@ -42,7 +42,7 @@ func (a *WikiClassApi) AddClass(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @accept json // @accept json
// @Produce application/json // @Produce application/json
// @Param data body lantReq.UpdateWikiClass true "修改分类" // @Param data body plantReq.UpdateWikiClass true "修改分类"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"发布成功"}" // @Success 200 {string} string "{"success":true,"data":{},"msg":"发布成功"}"
// @Router /wiki-class/update [post] // @Router /wiki-class/update [post]
func (a *WikiClassApi) UpdateClass(c *gin.Context) { func (a *WikiClassApi) UpdateClass(c *gin.Context) {
+8 -1
View File
@@ -137,7 +137,14 @@ func (m *MenuApi) GetAllMenuTree(c *gin.Context) {
// @Success 200 {object} response.Response{data=[]system.Menu,msg=string} "用户菜单数据" // @Success 200 {object} response.Response{data=[]system.Menu,msg=string} "用户菜单数据"
// @Router /menu/getUserMenuTree [get] // @Router /menu/getUserMenuTree [get]
func (m *MenuApi) GetUserMenuTree(c *gin.Context) { func (m *MenuApi) GetUserMenuTree(c *gin.Context) {
userId := auth.GetUserId(c)
routes, err := menuService.GetUserRoutes(userId)
if err != nil {
global.Logger.Error("获取用户菜单失败!", zap.Error(err))
response.FailWithMsg(err.Error(), c)
return
}
response.OkWithData(routes, c)
} }
// Route // Route
+13 -5
View File
@@ -1,5 +1,5 @@
system: system:
addr: 8888 addr: 8889
db-type: mysql db-type: mysql
router-prefix: "" router-prefix: ""
enable-captcha: 0 enable-captcha: 0
@@ -14,9 +14,14 @@ jwt:
# 植趣微信小程序 # 植趣微信小程序
#mini-program:
# app-id: wxb463820bf36dd5d6
# app-secret: 731784a74c76c6d31fa00bb847af2c7d
# 植遇微信小程序
mini-program: mini-program:
app-id: wxb463820bf36dd5d6 app-id: wx52dfc635739a9c19
app-secret: 731784a74c76c6d31fa00bb847af2c7d app-secret: 5aaed22f05352b7cd991870de6600bef
# 植趣服务号 # 植趣服务号
service-account: service-account:
@@ -32,6 +37,9 @@ wechat-pay:
public-key-path: /Users/blizzard/privateFolder/cert/pub_key.pem # 商户APIv3密钥对应的公钥 public-key-path: /Users/blizzard/privateFolder/cert/pub_key.pem # 商户APIv3密钥对应的公钥
notify-url: https://prod.sundynix.cn/api/wechatpay/notify # 微信支付结果通知回调地址 notify-url: https://prod.sundynix.cn/api/wechatpay/notify # 微信支付结果通知回调地址
baidu-img-classify:
api-key: hpBfjwy8ifv3qswYGYjUCNKN
secret-key: i5aXZdM4XZVuDroBslL0f3uIuwbAyXFS
minio: minio:
access-key-id: qP5QXP3g6Axw1hkwX21Y access-key-id: qP5QXP3g6Axw1hkwX21Y
@@ -74,8 +82,8 @@ redis:
- 172.21.0.4:7001 - 172.21.0.4:7001
- 172.21.0.2:7002 - 172.21.0.2:7002
db: 1 db: 1
# name: "" # name: ""
# password: "sundynix" # password: "sundynix"
cluster: false cluster: false
zap: zap:
+6
View File
@@ -0,0 +1,6 @@
package config
type BaiduImgClassify struct {
ApiKey string `mapstructure:"api-key" json:"api-key" yaml:"api-key"`
SecretKey string `mapstructure:"secret-key" json:"secret-key" yaml:"secret-key"`
}
+1
View File
@@ -16,4 +16,5 @@ type Config struct {
MiniProgram MiniProgram `mapstructure:"mini-program" json:"mini-program" yaml:"mini-program"` //小程序 MiniProgram MiniProgram `mapstructure:"mini-program" json:"mini-program" yaml:"mini-program"` //小程序
ServiceAccount ServiceAccount `mapstructure:"service-account" json:"service-account" yaml:"service-account"` //服务号 ServiceAccount ServiceAccount `mapstructure:"service-account" json:"service-account" yaml:"service-account"` //服务号
WechatPay WechatPay `mapstructure:"wechat-pay" json:"wechat-pay" yaml:"wechat-pay"` //微信支付 WechatPay WechatPay `mapstructure:"wechat-pay" json:"wechat-pay" yaml:"wechat-pay"` //微信支付
BaiduImgClassify BaiduImgClassify `mapstructure:"baidu-img-classify" json:"baidu-img-classify" yaml:"baidu-img-classify"` // 百度植物识别
} }
+918 -4345
View File
File diff suppressed because it is too large Load Diff
+918 -4345
View File
File diff suppressed because it is too large Load Diff
+635 -2737
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -54,6 +54,7 @@ func Routers() {
plantGroup.InitPostRouter(NeedAuthGroup) // 帖子相关 plantGroup.InitPostRouter(NeedAuthGroup) // 帖子相关
plantGroup.InitWikiClassRouter(NeedAuthGroup) //百科分类 plantGroup.InitWikiClassRouter(NeedAuthGroup) //百科分类
plantGroup.InitWikiRouter(NeedAuthGroup) //百科 plantGroup.InitWikiRouter(NeedAuthGroup) //百科
plantGroup.InitOcrRouter(NeedAuthGroup) // ocr识别
} }
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// @title Swagger API接口文档 // @title Swagger API接口文档
// @version v1.0.0 // @version v1.0.0
// @description 使用gin + gorm进行极速开发的全栈开发基础平台 // @description 使用gin + gorm进行极速开发的全栈开发基础平台
// @securityDefinitions.apikey ApiKeyAuth // @securityDefinitions.apikey BearerAuth
// @in header // @in header
// @name Authorization // @name Authorization
// @BasePath / // @BasePath /
-2
View File
@@ -12,6 +12,4 @@ type Oss struct {
Key string `json:"key" form:"key" gorm:"column:key;comment:文件key"` Key string `json:"key" form:"key" gorm:"column:key;comment:文件key"`
Suffix string `json:"suffix" form:"suffix" gorm:"column:suffix;comment:文件后缀"` Suffix string `json:"suffix" form:"suffix" gorm:"column:suffix;comment:文件后缀"`
MD5 string `json:"md5" form:"md5" gorm:"column:md5;comment:文件md5"` MD5 string `json:"md5" form:"md5" gorm:"column:md5;comment:文件md5"`
Height int `json:"height" form:"height" gorm:"column:height;comment:图片高度"`
Width int `json:"width" form:"width" gorm:"column:width;comment:图片宽度"`
} }
+2
View File
@@ -8,6 +8,7 @@ type RouterGroup struct {
PostRouter PostRouter
WikiClassRouter WikiClassRouter
WikiRouter WikiRouter
OcrRouter
} }
// 初始化路由 // 初始化路由
@@ -17,4 +18,5 @@ var (
postApi = v1.ApiGroupApp.PlantApiGroup.PostApi postApi = v1.ApiGroupApp.PlantApiGroup.PostApi
wikiClassApi = v1.ApiGroupApp.PlantApiGroup.WikiClassApi wikiClassApi = v1.ApiGroupApp.PlantApiGroup.WikiClassApi
wikiApi = v1.ApiGroupApp.PlantApiGroup.WikiApi wikiApi = v1.ApiGroupApp.PlantApiGroup.WikiApi
ocrApi = v1.ApiGroupApp.PlantApiGroup.OcrApi
) )
+13
View File
@@ -0,0 +1,13 @@
package plant
import "github.com/gin-gonic/gin"
type OcrRouter struct{}
func (c *OcrRouter) InitOcrRouter(Router *gin.RouterGroup) {
badgeRouter := Router.Group("classify")
{
badgeRouter.POST("/plant", ocrApi.ClassifyPlant)
}
}
+1
View File
@@ -10,6 +10,7 @@ func (p *PostRouter) InitPostRouter(Router *gin.RouterGroup) {
// 帖子 // 帖子
postRouter.POST("publish", postApi.PublishPost) // 发布帖子 postRouter.POST("publish", postApi.PublishPost) // 发布帖子
postRouter.POST("page", postApi.PostPage) // 帖子列表 postRouter.POST("page", postApi.PostPage) // 帖子列表
postRouter.POST("myPost", postApi.MyPost) // 我的发布
postRouter.GET("like", postApi.LikePost) // 点赞或者取消点赞 postRouter.GET("like", postApi.LikePost) // 点赞或者取消点赞
postRouter.POST("comment", postApi.CommentPost) // 评论 postRouter.POST("comment", postApi.CommentPost) // 评论
//postRouter.POST("deleteComment", postApi.delementComment) // 取消评论 //postRouter.POST("deleteComment", postApi.delementComment) // 取消评论
+1
View File
@@ -6,4 +6,5 @@ type ServiceGroup struct {
PostService PostService
WikiClassService WikiClassService
WikiService WikiService
OcrService
} }
+78
View File
@@ -0,0 +1,78 @@
package plant
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"sundynix-go/global"
"sundynix-go/model/plant/response"
"sundynix-go/pkg/httpclient"
"go.uber.org/zap"
)
type OcrService struct{}
// ClassifyPlant 植物识别
func (s *OcrService) ClassifyPlant(file multipart.File, header *multipart.FileHeader) (response.PlantRecognitionResponse, error) {
reqUrl := "https://aip.baidubce.com/rest/2.0/image-classify/v1/plant?access_token=" + getAccessToken()
// 3. 读取文件的全部字节
fileBytes, err := io.ReadAll(file)
if err != nil {
global.Logger.Error("读取文件失败!", zap.Error(err))
}
// 4. 将字节流编码为 Base64 字符串(可选 URLEncoding,根据场景选择)
// 去掉编码头进行urlencode
base64Str := base64.StdEncoding.EncodeToString(fileBytes)
escapedBase64 := url.QueryEscape(base64Str)
payload := strings.NewReader("image=" + escapedBase64 + "&baike_num=1")
myHttpClient := httpclient.GetClient()
req, err := http.NewRequest("POST", reqUrl, payload)
if err != nil {
global.Logger.Error("创建请求失败!", zap.Error(err))
return response.PlantRecognitionResponse{}, err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Accept", "application/json")
resp, err := myHttpClient.Do(req)
if err != nil {
global.Logger.Error("请求百度接口失败!", zap.Error(err))
return response.PlantRecognitionResponse{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
global.Logger.Error("解析百度接口响应失败!", zap.Error(err))
return response.PlantRecognitionResponse{}, err
}
// 3. 解析JSON到结构体
var plantResp response.PlantRecognitionResponse
if err = json.Unmarshal(body, &plantResp); err != nil {
global.Logger.Error("解析识别JSON失败!", zap.Error(err))
}
return plantResp, err
}
func getAccessToken() string {
rpcUrl := "https://aip.baidubce.com/oauth/2.0/token"
postData := fmt.Sprintf("grant_type=client_credentials&client_id=%s&client_secret=%s", global.Config.BaiduImgClassify.ApiKey, global.Config.BaiduImgClassify.SecretKey)
resp, err := http.Post(rpcUrl, "application/x-www-form-urlencoded", strings.NewReader(postData))
if err != nil {
fmt.Println(err)
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println(err)
return ""
}
accessTokenObj := map[string]any{}
_ = json.Unmarshal([]byte(body), &accessTokenObj)
return accessTokenObj["access_token"].(string)
}
+61
View File
@@ -111,6 +111,67 @@ func (s *PostService) PostPage(req plantReq.PostPage, userId string) (list inter
return posts, total, err return posts, total, err
} }
// MyPost 我的帖子
func (s *PostService) MyPost(req plantReq.PostPage, userId string) (list interface{}, total int64, err error) {
limit := req.PageSize
offset := req.PageSize * (req.Current - 1)
db := global.DB.Model(&plant.Post{}).
Preload("ImgList", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at desc")
}).
Preload("Publisher", func(db *gorm.DB) *gorm.DB {
return db.Preload("Avatar")
}).
Preload("LikeList", func(db *gorm.DB) *gorm.DB {
return db.Preload("Liker", func(db *gorm.DB) *gorm.DB {
return db.Preload("Avatar")
})
}).
Preload("CommentList", func(db *gorm.DB) *gorm.DB {
return db.Preload("Commentator", func(db *gorm.DB) *gorm.DB {
return db.Preload("Avatar")
})
})
var posts []plant.Post
db = db.Where("user_id = ?", userId)
if req.Title != "" {
db = db.Where("title like ?", "%"+req.Title+"%")
}
//todo 审核帖子
err = db.Count(&total).Error
if err != nil {
return
}
err = db.Limit(limit).Offset(offset).Order("created_at desc").Find(&posts).Error
// 优化 N+1 查询
var postIds []string
for _, v := range posts {
postIds = append(postIds, v.Id)
}
// 批量查询当前用户点赞的记录
var postLikeList []*plant.PostLike
err = global.DB.Where("user_id = ? and post_id in ?", userId, postIds).Find(&postLikeList).Error
if err != nil {
return
}
// 构建id映射
likesMap := make(map[string]bool)
for _, v := range postLikeList {
likesMap[v.PostId] = true
}
// 是否点赞
for i := range posts {
if likesMap[posts[i].Id] {
posts[i].HasLiked = 1
} else {
posts[i].HasLiked = 0
}
}
return posts, total, err
}
// LikePost 点赞帖或取消赞 // LikePost 点赞帖或取消赞
func (s *PostService) LikePost(userId, postId, class string) error { func (s *PostService) LikePost(userId, postId, class string) error {
// class = 1点赞 // class = 1点赞
+2 -2
View File
@@ -153,7 +153,7 @@ func (s *WikiService) WikiPage(req plantReq.WikiPage) (list interface{}, total i
limit := req.PageSize limit := req.PageSize
offset := req.PageSize * (req.Current - 1) offset := req.PageSize * (req.Current - 1)
db := global.DB.Model(&plant.Wiki{}).Preload("ImgList", func(db *gorm.DB) *gorm.DB { db := global.DB.Model(&plant.Wiki{}).Preload("ImgList", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at desc").Limit(1) return db.Order("created_at desc")
}).Preload("Classes", func(db *gorm.DB) *gorm.DB { }).Preload("Classes", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at desc") return db.Order("created_at desc")
}) })
@@ -165,7 +165,7 @@ func (s *WikiService) WikiPage(req plantReq.WikiPage) (list interface{}, total i
db = db.Where("is_hot = ?", *req.IsHot) db = db.Where("is_hot = ?", *req.IsHot)
} }
if len(req.ClassIdIs) > 0 { if len(req.ClassIdIs) > 0 {
db = db.Joins("inner join sundynix_wiki_class on sundynix_wiki_class.class.id = sundynix_wiki.id"). db = db.Joins("inner join sundynix_wiki_class on sundynix_wiki_class.wiki_id = sundynix_wiki.id").
Where("sundynix_wiki_class.class_id IN (?)", req.ClassIdIs) Where("sundynix_wiki_class.class_id IN (?)", req.ClassIdIs)
} }
err = db.Count(&total).Error err = db.Count(&total).Error
+5 -3
View File
@@ -21,9 +21,11 @@ func (s *WikiClassService) AddClass(req plantReq.CreateWikiClass) error {
if !errors.Is(global.DB.Where("name = ?", req.Name).First(&plant.Class{}).Error, gorm.ErrRecordNotFound) { if !errors.Is(global.DB.Where("name = ?", req.Name).First(&plant.Class{}).Error, gorm.ErrRecordNotFound) {
return errors.New("存在重复分类名称,请修改名称") return errors.New("存在重复分类名称,请修改名称")
} }
if req.OssId != "" {
if errors.Is(global.DB.Where("id = ?", req.OssId).First(&system.Oss{}).Error, gorm.ErrRecordNotFound) { if errors.Is(global.DB.Where("id = ?", req.OssId).First(&system.Oss{}).Error, gorm.ErrRecordNotFound) {
return errors.New("不存在此图片") return errors.New("不存在此图片")
} }
}
return global.DB.Create(&plant.Class{ return global.DB.Create(&plant.Class{
Name: req.Name, Name: req.Name,
OssId: req.OssId, OssId: req.OssId,
@@ -42,8 +44,8 @@ func (s *WikiClassService) UpdateClass(req plantReq.UpdateWikiClass) error {
func (s *WikiClassService) ClassPage(req common.PageInfo) (list interface{}, total int64, err error) { func (s *WikiClassService) ClassPage(req common.PageInfo) (list interface{}, total int64, err error) {
limit := req.PageSize limit := req.PageSize
offset := req.PageSize * (req.Current - 1) offset := req.PageSize * (req.Current - 1)
db := global.DB.Model(&plant.Class{}).Preload("Oss") db := global.DB.Model(&plant.Class{})
var classes []*plant.Class var classes []plant.Class
err = db.Count(&total).Error err = db.Count(&total).Error
if err != nil { if err != nil {
return return
@@ -65,7 +67,7 @@ func (s *WikiClassService) DeleteClass(req common.IdsReq) error {
// ClassList 列表 // ClassList 列表
func (s *WikiClassService) ClassList() (list interface{}, err error) { func (s *WikiClassService) ClassList() (list interface{}, err error) {
var classes []plant.Class var classes []plant.Class
err = global.DB.Order("created_at desc").Find(&classes).Error err = global.DB.Preload("Oss").Order("created_at desc").Find(&classes).Error
if err != nil { if err != nil {
return return
} }
-20
View File
@@ -4,7 +4,6 @@ import (
"crypto/md5" "crypto/md5"
"errors" "errors"
"fmt" "fmt"
"image"
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
@@ -58,24 +57,7 @@ func (o *OssService) Upload(multipartFile multipart.File, header *multipart.File
} }
//文件后缀 //文件后缀
s := strings.Split(header.Filename, ".") s := strings.Split(header.Filename, ".")
height := 0
width := 0
//mime类型
contentType := header.Header.Get("Content-Type")
allowedImageTypes := map[string]bool{
"image/jpeg": true,
"image/png": true,
}
isPossibleImage := allowedImageTypes[contentType]
//仅当可能是图片时 才计算图片宽高 //仅当可能是图片时 才计算图片宽高
if isPossibleImage {
img, _, err1 := image.Decode(multipartFile)
if err1 != nil {
return file, err
}
height = img.Bounds().Max.Y
width = img.Bounds().Max.X
}
f := system.Oss{ f := system.Oss{
Key: key, // uploads/2025-09-17/ Key: key, // uploads/2025-09-17/
Name: header.Filename, Name: header.Filename,
@@ -83,8 +65,6 @@ func (o *OssService) Upload(multipartFile multipart.File, header *multipart.File
Tag: s[len(s)-1], Tag: s[len(s)-1],
Url: filepath, // http://127.0.0.1:9000/planting-fun/uploads/2025-09-17/211476f3837fc7acbaebf0f901c1bd68.png Url: filepath, // http://127.0.0.1:9000/planting-fun/uploads/2025-09-17/211476f3837fc7acbaebf0f901c1bd68.png
MD5: hashString, MD5: hashString,
Height: height,
Width: width,
} }
return f, global.DB.Create(&f).Error return f, global.DB.Create(&f).Error
} }
+1 -1
View File
@@ -96,7 +96,7 @@ func (s *MenuService) GetUserRoutes(userId string) (menus []*system.Menu, err er
var menuIds []string var menuIds []string
err = global.DB.Model(&system.RoleMenu{}).Where("role_id in ?", roleIds).Pluck("menu_id", &menuIds).Error err = global.DB.Model(&system.RoleMenu{}).Where("role_id in ?", roleIds).Pluck("menu_id", &menuIds).Error
var menuList []*system.Menu var menuList []*system.Menu
err = global.DB.Model(&system.Menu{}).Where("id in ?", menuIds).Order("sort asc").Find(&menuList).Error err = global.DB.Model(&system.Menu{}).Where("id in ? and category = 1 ", menuIds).Order("sort asc").Find(&menuList).Error
return buildMenuTree(menuList), nil return buildMenuTree(menuList), nil
} }