feat: 植物识别百科ai助手迁移

This commit is contained in:
Blizzard
2026-05-24 01:41:22 +08:00
parent ae6d03d351
commit 076ed1509b
29 changed files with 1121 additions and 372 deletions
+124 -155
View File
@@ -1,131 +1,101 @@
package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
qdrant "github.com/qdrant/go-client/qdrant"
openai "github.com/sashabaranov/go-openai"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"sundynix-micro-go/app/plant/api/internal/svc"
plantModel "sundynix-micro-go/app/plant/model"
plantPb "sundynix-micro-go/app/plant/rpc/plant"
)
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
func getActiveAiConfig(svcCtx *svc.ServiceContext) (*plantModel.SysAiConfig, error) {
var cfg plantModel.SysAiConfig
err := svcCtx.DB.Where("is_active = 1").First(&cfg).Error
if err != nil {
return nil, errors.New("AI/RAG 问答服务暂未激活或数据库配置缺失")
}
return &cfg, nil
}
type chatRequest struct {
Model string `json:"model,omitempty"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
}
func chatModel(svcCtx *svc.ServiceContext) string {
if svcCtx.Config.Ai.ChatModelName != "" {
return svcCtx.Config.Ai.ChatModelName
func chatModel(dbCfg *plantModel.SysAiConfig) string {
if dbCfg.ChatModelName != "" {
return dbCfg.ChatModelName
}
return "gpt-4o-mini"
}
func requestBody(svcCtx *svc.ServiceContext, question string, stream bool) ([]byte, error) {
systemPrompt := "你是一个专业的植物百科助手。回答规则:基于知识库信息回答,不够则结合通用知识;不要使用 Markdown;用纯文本分段;回答简洁专业、条理清晰。"
if ctxText := retrieveRAGContext(context.Background(), svcCtx, question); ctxText != "" {
systemPrompt += "\n--- 知识库 ---\n" + ctxText + "\n--------------"
func newQdrantConn(cfg *plantModel.SysAiConfig) (*grpc.ClientConn, context.Context, error) {
addr := strings.TrimPrefix(cfg.QdrantUrl, "http://")
addr = strings.TrimPrefix(addr, "https://")
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, nil, fmt.Errorf("qdrant grpc dial failed: %w", err)
}
return json.Marshal(chatRequest{
Model: chatModel(svcCtx),
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: question},
},
Stream: stream,
})
ctx := context.Background()
if cfg.QdrantApiKey != "" {
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("api-key", cfg.QdrantApiKey))
}
return conn, ctx, nil
}
func retrieveRAGContext(ctx context.Context, svcCtx *svc.ServiceContext, question string) string {
c := svcCtx.Config.Ai
if c.EmbeddingApiUrl == "" || c.EmbeddingApiKey == "" || c.QdrantUrl == "" || c.QdrantCollection == "" {
return ""
}
body, _ := json.Marshal(map[string]interface{}{
"model": c.EmbeddingModelName,
"input": question,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.EmbeddingApiUrl, bytes.NewReader(body))
if err != nil {
return ""
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.EmbeddingApiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
var emb struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || json.NewDecoder(resp.Body).Decode(&emb) != nil || len(emb.Data) == 0 {
func retrieveRAGContext(ctx context.Context, svcCtx *svc.ServiceContext, dbCfg *plantModel.SysAiConfig, question string) string {
if dbCfg.EmbeddingApiUrl == "" || dbCfg.EmbeddingApiKey == "" || dbCfg.QdrantUrl == "" || dbCfg.QdrantCollection == "" {
return ""
}
searchBody, _ := json.Marshal(map[string]interface{}{
"vector": emb.Data[0].Embedding,
"limit": 3,
"with_payload": true,
config := openai.DefaultConfig(dbCfg.EmbeddingApiKey)
if dbCfg.EmbeddingApiUrl != "" {
config.BaseURL = dbCfg.EmbeddingApiUrl
}
client := openai.NewClientWithConfig(config)
embResp, err := client.CreateEmbeddings(ctx, openai.EmbeddingRequest{
Input: []string{question},
Model: openai.EmbeddingModel(dbCfg.EmbeddingModelName),
})
searchReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.QdrantUrl, "/")+"/collections/"+c.QdrantCollection+"/points/search", bytes.NewReader(searchBody))
if err != nil {
return ""
}
searchReq.Header.Set("Content-Type", "application/json")
if c.QdrantApiKey != "" {
searchReq.Header.Set("api-key", c.QdrantApiKey)
}
searchResp, err := http.DefaultClient.Do(searchReq)
if err != nil {
conn, qdCtx, connErr := newQdrantConn(dbCfg)
if connErr != nil {
return ""
}
defer searchResp.Body.Close()
var parsed struct {
Result []struct {
Payload map[string]interface{} `json:"payload"`
} `json:"result"`
}
if searchResp.StatusCode < 200 || searchResp.StatusCode >= 300 || json.NewDecoder(searchResp.Body).Decode(&parsed) != nil {
defer conn.Close()
ptsClient := qdrant.NewPointsClient(conn)
searchRes, searchErr := ptsClient.Search(qdCtx, &qdrant.SearchPoints{
CollectionName: dbCfg.QdrantCollection,
Vector: embResp.Data[0].Embedding,
Limit: 3,
WithPayload: &qdrant.WithPayloadSelector{
SelectorOptions: &qdrant.WithPayloadSelector_Enable{Enable: true},
},
})
if searchErr != nil {
return ""
}
var b strings.Builder
for _, item := range parsed.Result {
if text, ok := item.Payload["full_text"].(string); ok && text != "" {
b.WriteString(text)
for _, pt := range searchRes.GetResult() {
if txt, ok := pt.GetPayload()["full_text"]; ok {
b.WriteString(txt.GetStringValue())
b.WriteString("\n")
}
}
return b.String()
}
func newChatRequest(ctx context.Context, svcCtx *svc.ServiceContext, body []byte) (*http.Request, error) {
if svcCtx.Config.Ai.ChatApiUrl == "" || svcCtx.Config.Ai.ChatApiKey == "" {
return nil, errors.New("AI/RAG 未配置 ChatApiUrl 或 ChatApiKey")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, svcCtx.Config.Ai.ChatApiUrl, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+svcCtx.Config.Ai.ChatApiKey)
return req, nil
}
func SaveHistory(ctx context.Context, svcCtx *svc.ServiceContext, userID, question, answer string) {
if userID == "" || question == "" || answer == "" {
return
@@ -136,102 +106,100 @@ func SaveHistory(ctx context.Context, svcCtx *svc.ServiceContext, userID, questi
}
func ChatCompletion(ctx context.Context, svcCtx *svc.ServiceContext, userID, question string) (string, error) {
if err := ensureQuota(ctx, svcCtx, userID); err != nil {
return "", err
}
body, err := requestBody(svcCtx, question, false)
dbCfg, err := getActiveAiConfig(svcCtx)
if err != nil {
return "", err
}
req, err := newChatRequest(ctx, svcCtx, body)
if err != nil {
if err := ensureQuota(ctx, svcCtx, userID, dbCfg); err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return "", fmt.Errorf("AI 请求失败: %s %s", resp.Status, strings.TrimSpace(string(raw)))
}
var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
systemPrompt := "你是一个专业的植物百科助手。回答规则:基于知识库信息回答,不够则结合通用知识;不要使用 Markdown;用纯文本分段;回答简洁专业、条理清晰。"
if ctxText := retrieveRAGContext(ctx, svcCtx, dbCfg, question); ctxText != "" {
systemPrompt += "\n--- 知识库 ---\n" + ctxText + "\n--------------"
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
config := openai.DefaultConfig(dbCfg.ChatApiKey)
if dbCfg.ChatApiUrl != "" {
config.BaseURL = dbCfg.ChatApiUrl
}
client := openai.NewClientWithConfig(config)
resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: chatModel(dbCfg),
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: question},
},
})
if err != nil {
return "", err
}
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
return "", errors.New("AI 响应为空")
}
answer := parsed.Choices[0].Message.Content
answer := resp.Choices[0].Message.Content
SaveHistory(ctx, svcCtx, userID, question, answer)
return answer, nil
}
func StreamChat(ctx context.Context, svcCtx *svc.ServiceContext, userID, question string, w io.Writer) error {
if err := ensureQuota(ctx, svcCtx, userID); err != nil {
return err
}
body, err := requestBody(svcCtx, question, true)
dbCfg, err := getActiveAiConfig(svcCtx)
if err != nil {
return err
}
req, err := newChatRequest(ctx, svcCtx, body)
if err := ensureQuota(ctx, svcCtx, userID, dbCfg); err != nil {
return err
}
systemPrompt := "你是一个专业的植物百科助手。回答规则:基于知识库信息回答,不够则结合通用知识;不要使用 Markdown;用纯文本分段;回答简洁专业、条理清晰。"
if ctxText := retrieveRAGContext(ctx, svcCtx, dbCfg, question); ctxText != "" {
systemPrompt += "\n--- 知识库 ---\n" + ctxText + "\n--------------"
}
config := openai.DefaultConfig(dbCfg.ChatApiKey)
if dbCfg.ChatApiUrl != "" {
config.BaseURL = dbCfg.ChatApiUrl
}
client := openai.NewClientWithConfig(config)
stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: chatModel(dbCfg),
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: question},
},
Stream: true,
})
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("AI 请求失败: %s %s", resp.Status, strings.TrimSpace(string(raw)))
}
defer stream.Close()
var answer strings.Builder
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
_, _ = fmt.Fprintln(w, line)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
for {
resp, recvErr := stream.Recv()
if errors.Is(recvErr, io.EOF) {
break
}
if recvErr != nil {
return recvErr
}
if len(resp.Choices) > 0 {
content := resp.Choices[0].Delta.Content
if content != "" {
_, _ = fmt.Fprintf(w, "data: %s\n\n", content)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
answer.WriteString(content)
}
}
}
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
if data == "[DONE]" {
continue
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if json.Unmarshal([]byte(data), &chunk) == nil && len(chunk.Choices) > 0 {
answer.WriteString(chunk.Choices[0].Delta.Content)
}
}
if err := scanner.Err(); err != nil {
return err
}
SaveHistory(ctx, svcCtx, userID, question, answer.String())
return nil
}
func ensureQuota(ctx context.Context, svcCtx *svc.ServiceContext, userID string) error {
func ensureQuota(ctx context.Context, svcCtx *svc.ServiceContext, userID string, dbCfg *plantModel.SysAiConfig) error {
if userID == "" {
return nil
}
@@ -239,8 +207,9 @@ func ensureQuota(ctx context.Context, svcCtx *svc.ServiceContext, userID string)
if err != nil {
return err
}
if quota.Limit > 0 && quota.Remaining <= 0 {
return fmt.Errorf("今日问答次数已达上限(%d次),明天再来吧", quota.Limit)
limit := int64(dbCfg.DailyQueryLimit)
if limit > 0 && quota.Remaining <= 0 {
return fmt.Errorf("今日问答次数已达上限(%d次),明天再来吧", limit)
}
return nil
}
@@ -3,10 +3,13 @@ package myPlant
import (
"context"
"fmt"
"github.com/zeromicro/go-zero/core/logx"
"sundynix-micro-go/app/plant/api/internal/svc"
"sundynix-micro-go/app/plant/api/internal/types"
"sundynix-micro-go/app/plant/rpc/plant"
plantModel "sundynix-micro-go/app/plant/model"
"github.com/zeromicro/go-zero/core/logx"
"gorm.io/gorm"
)
type AddGrowthRecordLogic struct {
@@ -21,8 +24,36 @@ func NewAddGrowthRecordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *A
func (l *AddGrowthRecordLogic) AddGrowthRecord(req *types.GrowthRecordReq) error {
userId := fmt.Sprintf("%v", l.ctx.Value("userId"))
_, err := l.svcCtx.PlantRpc.AddGrowthRecord(l.ctx, &plant.AddGrowthRecordReq{
UserId: userId, PlantId: req.PlantId, Content: req.Content, ImgIds: req.ImgIds,
imgIds := req.ImgIds
if len(imgIds) == 0 && len(req.OssIds) > 0 {
imgIds = req.OssIds
}
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
record := plantModel.GrowthRecord{
UserID: userId,
PlantID: req.PlantId,
Name: req.Name,
Tag: req.Tag,
Desc: req.Desc,
Content: req.Content,
}
if err := tx.Create(&record).Error; err != nil {
return err
}
// 保存图片关联
if len(imgIds) > 0 {
relations := make([]plantModel.GrowthRecordOss, 0, len(imgIds))
for _, ossId := range imgIds {
relations = append(relations, plantModel.GrowthRecordOss{
GrowthRecordID: record.ID, OssID: ossId,
})
}
if err := tx.Create(&relations).Error; err != nil {
return err
}
}
return nil
})
return err
}
@@ -3,9 +3,11 @@ package ocr
import (
"context"
"fmt"
"time"
"github.com/zeromicro/go-zero/core/logx"
"sundynix-micro-go/app/plant/api/internal/svc"
plantPb "sundynix-micro-go/app/plant/rpc/plant"
plantModel "sundynix-micro-go/app/plant/model"
)
type GetMyClassifyLogLogic struct {
@@ -14,11 +16,53 @@ type GetMyClassifyLogLogic struct {
svcCtx *svc.ServiceContext
}
type ClassifyRecordResp struct {
List []ClassifyRecordInfo `json:"list"`
Total int64 `json:"total"`
}
type ClassifyRecordInfo struct {
ID string `json:"id"`
UserID string `json:"userId"`
LogID uint64 `json:"logId"`
AllResults plantModel.ResultsArray `json:"allResults"`
CreatedAt string `json:"createdAt"`
CreatedAtStr string `json:"createdAtStr"`
}
func NewGetMyClassifyLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMyClassifyLogLogic {
return &GetMyClassifyLogLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetMyClassifyLogLogic) GetMyClassifyLog() (*plantPb.ClassifyLogListResp, error) {
func (l *GetMyClassifyLogLogic) GetMyClassifyLog() (*ClassifyRecordResp, error) {
userId := fmt.Sprintf("%v", l.ctx.Value("userId"))
return l.svcCtx.PlantRpc.GetMyClassifyLog(l.ctx, &plantPb.GetProfileReq{UserId: userId})
var records []plantModel.ClassifyRecord
var total int64
db := l.svcCtx.DB.Model(&plantModel.ClassifyRecord{}).Where("user_id = ?", userId)
if err := db.Count(&total).Error; err != nil {
return nil, err
}
if err := db.Order("created_at desc").Limit(50).Find(&records).Error; err != nil {
return nil, err
}
list := make([]ClassifyRecordInfo, 0, len(records))
for _, og := range records {
list = append(list, ClassifyRecordInfo{
ID: og.ID,
UserID: og.UserID,
LogID: og.LogID,
AllResults: og.AllResults,
CreatedAt: og.CreatedAt.Format(time.RFC3339),
CreatedAtStr: og.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return &ClassifyRecordResp{
List: list,
Total: total,
}, nil
}
@@ -14,6 +14,7 @@ import (
"sundynix-micro-go/app/plant/api/internal/svc"
"sundynix-micro-go/app/plant/api/internal/types"
plantModel "sundynix-micro-go/app/plant/model"
"github.com/zeromicro/go-zero/core/logx"
)
@@ -83,7 +84,51 @@ func (l *OcrClassifyLogic) OcrClassify(req *types.OcrReq) (interface{}, error) {
return nil, fmt.Errorf("读取识别结果失败: %w", err)
}
// 3. 直接返回百度原始结果(前端自行解析 result 字段)
// 3. 解析为结构化识别结果并写入 ClassifyRecord 表
var baiduResp struct {
LogId uint64 `json:"log_id"`
Result []struct {
Score float64 `json:"score"`
Name string `json:"name"`
BaikeInfo *struct {
BaikeUrl string `json:"baike_url"`
ImageUrl string `json:"image_url"`
Description string `json:"description"`
} `json:"baike_info"`
} `json:"result"`
}
_ = json.Unmarshal(body, &baiduResp)
if baiduResp.LogId > 0 {
var dbResults plantModel.ResultsArray = make(plantModel.ResultsArray, 0, len(baiduResp.Result))
for _, item := range baiduResp.Result {
var baikeInfo *plantModel.BaikeInfo
if item.BaikeInfo != nil {
baikeInfo = &plantModel.BaikeInfo{
BaikeUrl: item.BaikeInfo.BaikeUrl,
ImageUrl: item.BaikeInfo.ImageUrl,
Description: item.BaikeInfo.Description,
}
}
dbResults = append(dbResults, plantModel.ResultItem{
Score: item.Score,
Name: item.Name,
BaikeInfo: baikeInfo,
})
}
userID := fmt.Sprintf("%v", l.ctx.Value("userId"))
record := plantModel.ClassifyRecord{
UserID: userID,
LogID: baiduResp.LogId,
AllResults: dbResults,
}
if errDb := l.svcCtx.DB.Create(&record).Error; errDb != nil {
l.Logger.Errorf("植物识别记录写入数据库失败: %v", errDb)
}
}
// 4. 直接返回百度原始结果
var result interface{}
if err = json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析识别结果失败: %w", err)
@@ -31,12 +31,16 @@ func NewCreatePostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Create
func (l *CreatePostLogic) CreatePost(req *types.CreatePostReq) error {
userId := fmt.Sprintf("%v", l.ctx.Value("userId"))
imgIds := req.ImgIds
if len(imgIds) == 0 && len(req.OssIds) > 0 {
imgIds = req.OssIds
}
_, err := l.svcCtx.PlantRpc.CreatePost(l.ctx, &plant.CreatePostReq{
UserId: userId,
Title: req.Title,
Content: req.Content,
Location: req.Location,
ImgIds: req.ImgIds,
ImgIds: imgIds,
TopicId: req.TopicId,
})
return err
@@ -3,10 +3,15 @@ package userProfile
import (
"context"
"fmt"
"github.com/zeromicro/go-zero/core/logx"
"time"
filePb "sundynix-micro-go/app/file/rpc/file"
"sundynix-micro-go/app/plant/api/internal/svc"
"sundynix-micro-go/app/plant/api/internal/types"
plantPb "sundynix-micro-go/app/plant/rpc/plant"
plantModel "sundynix-micro-go/app/plant/model"
"github.com/zeromicro/go-zero/core/logx"
"gorm.io/gorm"
)
type GetMyStarsLogic struct {
@@ -19,7 +24,348 @@ func NewGetMyStarsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMyS
return &GetMyStarsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetMyStarsLogic) GetMyStars(req *types.PageReq) (*plantPb.UserStarListResp, error) {
func (l *GetMyStarsLogic) GetMyStars(req *types.PageReq) (interface{}, error) {
userId := fmt.Sprintf("%v", l.ctx.Value("userId"))
return l.svcCtx.PlantRpc.GetMyStars(l.ctx, &plantPb.GetProfileReq{UserId: userId})
db := l.svcCtx.DB.Model(&plantModel.UserStar{}).Where("user_id = ?", userId)
if req.Class == 1 {
db = db.Where("type = ?", "wiki")
} else if req.Class == 2 {
db = db.Where("type = ?", "post")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 10
}
page := req.Current
if page <= 0 {
page = 1
}
offset := (page - 1) * pageSize
var stars []*plantModel.UserStar
if err := db.Limit(pageSize).Offset(offset).Order("created_at desc").Find(&stars).Error; err != nil {
return nil, err
}
// 提取 TargetID
var wikiIds []string
var postIds []string
for _, s := range stars {
if s.Type == "wiki" {
wikiIds = append(wikiIds, s.TargetID)
} else if s.Type == "post" {
postIds = append(postIds, s.TargetID)
}
}
// 1. 查询 Wiki 详情
wikiMap := make(map[string]map[string]interface{})
if len(wikiIds) > 0 {
var wikis []*plantModel.Wiki
if err := l.svcCtx.DB.Where("id IN ?", wikiIds).Find(&wikis).Error; err == nil {
// 查本地 WikiOss
type rel struct {
WikiID string `gorm:"column:wiki_id"`
OssID string `gorm:"column:oss_id"`
}
var rels []rel
l.svcCtx.DB.Table("sundynix_plant_wiki_oss").Where("wiki_id IN ?", wikiIds).Find(&rels)
wikiOssMap := make(map[string][]string)
var allOssIds []string
for _, r := range rels {
wikiOssMap[r.WikiID] = append(wikiOssMap[r.WikiID], r.OssID)
allOssIds = append(allOssIds, r.OssID)
}
// 通过 FileRpc 获取图片信息
fileMap := l.fetchFileMap(allOssIds)
for _, w := range wikis {
ossIds := wikiOssMap[w.ID]
imgList := l.imgListToList(fileMap, ossIds)
wikiMap[w.ID] = map[string]interface{}{
"id": w.ID, "name": w.Name, "latinName": w.LatinName,
"aliases": w.Aliases, "genus": w.Genus, "difficulty": w.Difficulty,
"isHot": w.IsHot, "growthHabit": w.GrowthHabit,
"lightIntensity": w.LightIntensity, "classId": w.ClassID,
"createdAt": w.CreatedAt.Format("2006-01-02 15:04:05"),
"hasStar": 1, // 既然在这个列表中,说明一定是被收藏的
"imgList": imgList,
}
}
}
}
// 2. 查询 Post 详情
postMap := make(map[string]map[string]interface{})
if len(postIds) > 0 {
var posts []*plantModel.Post
if err := l.svcCtx.DB.
Preload("CommentList", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at asc")
}).
Preload("LikeList").
Where("id IN ?", postIds).Find(&posts).Error; err == nil {
// 查帖子图片
postImgMap := l.queryPostImages(postIds)
// 查用户信息
allUserIds := l.collectPostUserIds(posts)
userMap := l.queryUserMap(allUserIds)
// 点赞收藏状态
likedSet, starredSet := l.queryLikeStarStatus(userId, postIds)
for _, p := range posts {
item := map[string]interface{}{
"id": p.ID, "title": p.Title, "content": p.Content,
"userId": p.UserID, "location": p.Location,
"viewCount": p.ViewCount, "commentCount": p.CommentCount,
"likeCount": p.LikeCount, "starCount": p.StarCount,
"hasReviewed": p.HasReviewed,
"createdAt": p.CreatedAt.Format(time.RFC3339),
"updatedAt": p.UpdatedAt.Format(time.RFC3339),
"createdAtStr": p.CreatedAt.Format("2006-01-02 15:04:05"),
"hasLiked": 0, "hasStar": 0,
"imgList": postImgMap[p.ID],
"publisher": l.buildPublisherInfo(userMap, p.UserID),
"commentList": l.buildCommentList(userMap, p.CommentList),
"likeList": l.buildLikeList(userMap, p.LikeList),
"starList": []map[string]interface{}{},
}
if likedSet[p.ID] {
item["hasLiked"] = 1
}
if starredSet[p.ID] {
item["hasStar"] = 1
}
postMap[p.ID] = item
}
}
}
// 3. 按照 Stars 排序组装最终 List
var list []map[string]interface{}
for _, s := range stars {
item := map[string]interface{}{
"id": s.ID, "userId": s.UserID, "targetId": s.TargetID, "type": s.Type,
"createdAt": s.CreatedAt.Format("2006-01-02 15:04:05"),
}
if s.Type == "wiki" {
if w, ok := wikiMap[s.TargetID]; ok {
item["wiki"] = w
list = append(list, item)
}
} else if s.Type == "post" {
if p, ok := postMap[s.TargetID]; ok {
item["post"] = p
list = append(list, item)
}
}
}
if list == nil {
list = []map[string]interface{}{}
}
return map[string]interface{}{
"list": list,
"total": total,
"page": page,
"pageSize": pageSize,
}, nil
}
func (l *GetMyStarsLogic) fetchFileMap(ids []string) map[string]map[string]interface{} {
result := make(map[string]map[string]interface{})
if len(ids) == 0 {
return result
}
resp, err := l.svcCtx.FileRpc.GetFilesByIds(l.ctx, &filePb.GetFilesByIdsReq{Ids: ids})
if err != nil || resp == nil {
return result
}
for _, f := range resp.Files {
result[f.Id] = map[string]interface{}{
"id": f.Id, "name": f.Name, "url": f.Url, "tag": f.Tag,
"key": f.Key, "suffix": f.Suffix, "md5": f.Md5,
"createdAt": time.Unix(f.CreatedAt, 0).Format(time.RFC3339),
"updatedAt": time.Unix(f.CreatedAt, 0).Format(time.RFC3339),
"createdAtStr": time.Unix(f.CreatedAt, 0).Format("2006-01-02 15:04:05"),
}
}
return result
}
func (l *GetMyStarsLogic) imgListToList(fileMap map[string]map[string]interface{}, ossIds []string) []map[string]interface{} {
var list []map[string]interface{}
for _, id := range ossIds {
if img, ok := fileMap[id]; ok {
list = append(list, img)
}
}
if list == nil {
list = []map[string]interface{}{}
}
return list
}
func (l *GetMyStarsLogic) queryPostImages(postIds []string) map[string][]map[string]interface{} {
type rel struct {
PostID string `gorm:"column:post_id"`
OssID string `gorm:"column:oss_id"`
}
var rels []rel
l.svcCtx.DB.Table("sundynix_plant_post_oss").Where("post_id IN ?", postIds).Find(&rels)
var allOssIds []string
pidMap := make(map[string][]string)
for _, r := range rels {
pidMap[r.PostID] = append(pidMap[r.PostID], r.OssID)
allOssIds = append(allOssIds, r.OssID)
}
fileInfos := l.fetchFileMap(allOssIds)
result := make(map[string][]map[string]interface{})
for pid, ids := range pidMap {
var imgs []map[string]interface{}
for _, oid := range ids {
if info, ok := fileInfos[oid]; ok {
imgs = append(imgs, info)
}
}
if imgs == nil {
imgs = []map[string]interface{}{}
}
result[pid] = imgs
}
for _, pid := range postIds {
if _, ok := result[pid]; !ok {
result[pid] = []map[string]interface{}{}
}
}
return result
}
func (l *GetMyStarsLogic) collectPostUserIds(posts []*plantModel.Post) []string {
set := make(map[string]bool)
for _, p := range posts {
set[p.UserID] = true
for _, c := range p.CommentList {
set[c.UserID] = true
}
for _, l := range p.LikeList {
set[l.UserID] = true
}
}
var ids []string
for id := range set {
ids = append(ids, id)
}
return ids
}
func (l *GetMyStarsLogic) queryUserMap(ids []string) map[string]map[string]interface{} {
result := make(map[string]map[string]interface{})
if len(ids) == 0 {
return result
}
type userRow struct {
ID string `gorm:"column:id"`
NickName string `gorm:"column:nick_name"`
Name string `gorm:"column:name"`
AvatarID string `gorm:"column:avatar_id"`
}
var rows []userRow
l.svcCtx.DB.Table("sundynix_user").Where("id IN ?", ids).Find(&rows)
var avatarIds []string
for _, row := range rows {
if row.AvatarID != "" {
avatarIds = append(avatarIds, row.AvatarID)
}
}
avatarMap := l.fetchFileMap(avatarIds)
for _, row := range rows {
avatarData := map[string]interface{}{}
if av, ok := avatarMap[row.AvatarID]; ok {
avatarData = av
}
result[row.ID] = map[string]interface{}{
"id": row.ID, "nickName": row.NickName, "name": row.Name,
"avatarId": row.AvatarID, "avatar": avatarData,
}
}
return result
}
func (l *GetMyStarsLogic) queryLikeStarStatus(userId string, postIds []string) (likedSet, starredSet map[string]bool) {
likedSet = make(map[string]bool)
starredSet = make(map[string]bool)
if len(postIds) == 0 {
return
}
type rel struct {
PostID string `gorm:"column:post_id"`
}
var likes []rel
l.svcCtx.DB.Table("sundynix_plant_post_like").Where("post_id IN ? AND user_id = ?", postIds, userId).Find(&likes)
for _, l := range likes {
likedSet[l.PostID] = true
}
var stars []rel
l.svcCtx.DB.Table("sundynix_plant_user_star").Where("target_id IN ? AND user_id = ? AND type = 'post'", postIds, userId).Find(&stars)
for _, s := range stars {
starredSet[s.PostID] = true
}
return
}
func (l *GetMyStarsLogic) buildPublisherInfo(userMap map[string]map[string]interface{}, userId string) map[string]interface{} {
if u, ok := userMap[userId]; ok {
return u
}
return map[string]interface{}{
"id": userId, "nickName": "", "name": "", "avatarId": "", "avatar": map[string]interface{}{},
}
}
func (l *GetMyStarsLogic) buildCommentList(userMap map[string]map[string]interface{}, comments []*plantModel.PostComment) []map[string]interface{} {
var list []map[string]interface{}
for _, c := range comments {
list = append(list, map[string]interface{}{
"id": c.ID, "postId": c.PostID, "userId": c.UserID,
"content": c.Content, "parentId": c.ParentID,
"createdAt": c.CreatedAt.Format(time.RFC3339),
"updatedAt": c.UpdatedAt.Format(time.RFC3339),
"createdAtStr": c.CreatedAt.Format("2006-01-02 15:04:05"),
"commentator": l.buildPublisherInfo(userMap, c.UserID),
})
}
if list == nil {
list = []map[string]interface{}{}
}
return list
}
func (l *GetMyStarsLogic) buildLikeList(userMap map[string]map[string]interface{}, likes []*plantModel.PostLike) []map[string]interface{} {
var list []map[string]interface{}
for _, like := range likes {
list = append(list, map[string]interface{}{
"id": like.ID, "postId": like.PostID, "userId": like.UserID,
"liker": l.buildPublisherInfo(userMap, like.UserID),
})
}
if list == nil {
list = []map[string]interface{}{}
}
return list
}
@@ -29,9 +29,13 @@ func NewCreateWikiClassLogic(ctx context.Context, svcCtx *svc.ServiceContext) *C
}
func (l *CreateWikiClassLogic) CreateWikiClass(req *types.WikiClassReq) error {
icon := req.Icon
if icon == "" && req.OssId != "" {
icon = req.OssId
}
_, err := l.svcCtx.PlantRpc.CreateWikiClass(l.ctx, &plant.CreateWikiClassReq{
Name: req.Name,
Icon: req.Icon,
Icon: icon,
})
return err
}
@@ -19,8 +19,12 @@ func NewUpdateWikiClassLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U
}
func (l *UpdateWikiClassLogic) UpdateWikiClass(req *types.UpdateWikiClassReq) error {
icon := req.Icon
if icon == "" && req.OssId != "" {
icon = req.OssId
}
_, err := l.svcCtx.PlantRpc.UpdateWikiClass(l.ctx, &plantPb.UpdateWikiClassReq{
Id: req.Id, Name: req.Name, Icon: req.Icon,
Id: req.Id, Name: req.Name, Icon: icon,
})
return err
}