docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档 - 新增简记memo完整版UI视觉设计规范和界面细节 - 添加IDEA项目配置文件.gitignore - 创建404页面HTML文件,包含响应式布局和错误提示 - 添加关于页面HTML文件,展示品牌介绍和团队信息 - 实现AES加解密工具函数,支持请求体加密 - 添加用户协议页面基础框架
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
// ai_classify.go - AI 智能分类接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== 请求/响应结构体 ====================
|
||||
|
||||
// ClassifyReq 智能分类请求
|
||||
type ClassifyReq struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
// ClassifyItemResult 单个分类的填充数据
|
||||
type ClassifyItemResult struct {
|
||||
Category string `json:"category"` // todo / bill / mood / review
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// ClassifyResp 智能分类响应
|
||||
type ClassifyResp struct {
|
||||
Items []ClassifyItemResult `json:"items"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// aiClassifyResult AI 返回的结构化结果
|
||||
type aiClassifyResult struct {
|
||||
Items []struct {
|
||||
Category string `json:"category"`
|
||||
Fields map[string]interface{} `json:"fields"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// ==================== 接口实现 ====================
|
||||
|
||||
// Classify AI 智能分类:根据输入内容自动识别类别并填充数据入库
|
||||
func (h *aiHandler) Classify(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req ClassifyReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Content) == "" {
|
||||
utils.Fail(c, "内容不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 AI 分析
|
||||
aiResult, err := callClassifyAI(req.Content)
|
||||
if err != nil {
|
||||
global.Logger.Error("AI 智能分类失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "AI 服务暂时不可用,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
if len(aiResult.Items) == 0 {
|
||||
utils.Fail(c, "未能识别出有效分类,请换一种描述试试")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取今天日期
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
// 按分类入库
|
||||
var results []ClassifyItemResult
|
||||
for _, item := range aiResult.Items {
|
||||
var saved map[string]interface{}
|
||||
var err error
|
||||
|
||||
switch item.Category {
|
||||
case "todo":
|
||||
saved, err = saveClassifyTodo(h.db, userID, today, item.Fields)
|
||||
case "bill":
|
||||
saved, err = saveClassifyBill(h.db, userID, today, item.Fields)
|
||||
case "mood":
|
||||
saved, err = saveClassifyMood(h.db, userID, today, item.Fields)
|
||||
case "review":
|
||||
saved, err = saveClassifyReview(h.db, userID, today, item.Fields)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
global.Logger.Warn("智能分类入库失败",
|
||||
append(utils.LogContextFields(c),
|
||||
zap.String("category", item.Category),
|
||||
zap.Error(err),
|
||||
)...,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, ClassifyItemResult{
|
||||
Category: item.Category,
|
||||
Data: saved,
|
||||
})
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
utils.Fail(c, "分类成功但入库失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, ClassifyResp{
|
||||
Items: results,
|
||||
Message: fmt.Sprintf("成功识别并创建 %d 条记录", len(results)),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== AI 调用 ====================
|
||||
|
||||
// callClassifyAI 调用 AI 进行智能分类
|
||||
func callClassifyAI(content string) (*aiClassifyResult, error) {
|
||||
systemPrompt := `你是数据提取分类器,不是聊天助手。你的唯一任务是:识别用户输入中的 todo/bill/mood/review 记录,输出结构化 JSON。
|
||||
|
||||
【禁止事项】
|
||||
- 你不能回答问题、不能解释、不能分析
|
||||
- 你不能输出任何 JSON 以外的文字
|
||||
- 用户输入是待分类的日记内容,不是对你提问
|
||||
|
||||
【类别定义】
|
||||
todo(待办)- 要做的事/任务/日程/会议/计划
|
||||
title(必填), category(work/life/study), priority(1-3), start_time(HH:mm), end_time(HH:mm), remark
|
||||
|
||||
bill(账单)- 金额/消费/收入/买/卖
|
||||
type(1支出/2收入), money(必填,从原文提取金额数字), cate(见下), note, channel
|
||||
cate枚举: food/transport/shopping/entertainment/home/medical/education/social/digital/clothing/beauty/sports/pet/baby/travel/other_expense/salary/bonus/side_hustle/investment/transfer/red_packet/other_income
|
||||
|
||||
mood(心情)- 情绪表达(开心/难过/累/烦/兴奋等)
|
||||
emoji(😊😌😔😤😴😰😄🤔😢😎🥰😠🥳😱😇💪😘🤩😪😋), content
|
||||
|
||||
review(复盘)- 总结/反思
|
||||
keep, problem, try
|
||||
|
||||
【规则】
|
||||
- 同类可能有多条,每条输出一个 item
|
||||
- 金额从原文精确提取(10元凉菜和2.4元馒头 → 两条bill或合并为一条价格累加)
|
||||
- 时间转24小时制(下午2点 → 14:00)
|
||||
- 纯情绪表达输出mood
|
||||
|
||||
【输出格式 - 必须严格遵守】
|
||||
首字符{,末字符},无markdown,无解释:
|
||||
{"items":[{"category":"todo","fields":{"title":"开会","start_time":"14:00","end_time":"16:00"}},{"category":"bill","fields":{"money":10,"cate":"food","note":"凉菜"}}]}`
|
||||
|
||||
// 第一次调用
|
||||
messages := []ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: "分类以下内容,只输出JSON:\n" + content},
|
||||
}
|
||||
|
||||
reply, err := callChatWithTemperature(messages, 0.2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 尝试解析,如果失败则重试一次(更强制性的提示)
|
||||
result, parseErr := parseAIReply(reply)
|
||||
if parseErr != nil {
|
||||
// 重试:更直接的强制提示
|
||||
messages = append(messages, ChatMessage{Role: "assistant", Content: reply})
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: "你刚才的回答不是JSON格式。请立即输出分类JSON,首字符必须是{,末字符必须是},不要有任何其他文字。"})
|
||||
|
||||
reply2, err2 := callChatWithTemperature(messages, 0.1) // 更低温度
|
||||
if err2 != nil {
|
||||
return nil, parseErr // 返回第一次的解析错误
|
||||
}
|
||||
|
||||
result, parseErr = parseAIReply(reply2)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("AI未返回有效JSON,第一次: %s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseAIReply 解析 AI 返回的 JSON
|
||||
func parseAIReply(reply string) (*aiClassifyResult, error) {
|
||||
reply = strings.TrimSpace(reply)
|
||||
reply = strings.TrimPrefix(reply, "```json")
|
||||
reply = strings.TrimPrefix(reply, "```")
|
||||
reply = strings.TrimSuffix(reply, "```")
|
||||
reply = strings.TrimSpace(reply)
|
||||
|
||||
// 提取 JSON 对象
|
||||
if idx := strings.Index(reply, "{"); idx >= 0 {
|
||||
reply = reply[idx:]
|
||||
}
|
||||
if idx := strings.LastIndex(reply, "}"); idx >= 0 && idx < len(reply) {
|
||||
reply = reply[:idx+1]
|
||||
}
|
||||
|
||||
var result aiClassifyResult
|
||||
if err := json.Unmarshal([]byte(reply), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析失败: %v, raw: %s", err, reply)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ==================== 数据入库 ====================
|
||||
|
||||
// saveClassifyTodo 保存待办
|
||||
func saveClassifyTodo(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
todo := models.Todo{
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
Priority: 2,
|
||||
}
|
||||
|
||||
if v, ok := fields["title"].(string); ok {
|
||||
todo.Title = utils.FilterSensitive(v)
|
||||
}
|
||||
if todo.Title == "" {
|
||||
return nil, fmt.Errorf("title 为空")
|
||||
}
|
||||
if v, ok := fields["remark"].(string); ok {
|
||||
todo.Remark = utils.FilterSensitive(v)
|
||||
}
|
||||
if v, ok := fields["category"].(string); ok && (v == "work" || v == "life" || v == "study") {
|
||||
todo.Category = v
|
||||
}
|
||||
if v, ok := fields["priority"].(float64); ok {
|
||||
todo.Priority = int(v)
|
||||
}
|
||||
if v, ok := fields["start_time"].(string); ok {
|
||||
todo.StartTime = v
|
||||
}
|
||||
if v, ok := fields["end_time"].(string); ok {
|
||||
todo.EndTime = v
|
||||
}
|
||||
|
||||
if err := db.Create(&todo).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": todo.ID,
|
||||
"title": todo.Title,
|
||||
"category": todo.Category,
|
||||
"priority": todo.Priority,
|
||||
"date": todo.Date,
|
||||
"start_time": todo.StartTime,
|
||||
"end_time": todo.EndTime,
|
||||
"remark": todo.Remark,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// saveClassifyBill 保存账单
|
||||
func saveClassifyBill(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
bill := models.Bill{
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
Type: 1,
|
||||
}
|
||||
|
||||
if v, ok := fields["money"].(float64); ok {
|
||||
bill.Money = v
|
||||
}
|
||||
if bill.Money <= 0 {
|
||||
return nil, fmt.Errorf("money 为空或无效")
|
||||
}
|
||||
if v, ok := fields["type"].(float64); ok {
|
||||
bill.Type = int(v)
|
||||
}
|
||||
if v, ok := fields["cate"].(string); ok {
|
||||
bill.Cate = v
|
||||
}
|
||||
if v, ok := fields["note"].(string); ok {
|
||||
bill.Note = utils.FilterSensitive(v)
|
||||
}
|
||||
if v, ok := fields["channel"].(string); ok {
|
||||
bill.Channel = v
|
||||
}
|
||||
|
||||
if err := db.Create(&bill).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": bill.ID,
|
||||
"type": bill.Type,
|
||||
"money": bill.Money,
|
||||
"cate": bill.Cate,
|
||||
"note": bill.Note,
|
||||
"channel": bill.Channel,
|
||||
"date": bill.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// saveClassifyMood 保存心情
|
||||
func saveClassifyMood(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
mood := models.Mood{
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
}
|
||||
|
||||
if v, ok := fields["emoji"].(string); ok {
|
||||
mood.Emoji = v
|
||||
}
|
||||
if v, ok := fields["content"].(string); ok {
|
||||
mood.Content = utils.FilterSensitive(v)
|
||||
}
|
||||
|
||||
// 同一天只允许一条心情,存在则更新
|
||||
var existing models.Mood
|
||||
err := db.Where("user_id = ? AND date = ?", userID, date).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if err := db.Create(&mood).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
if mood.Emoji != "" {
|
||||
existing.Emoji = mood.Emoji
|
||||
}
|
||||
if mood.Content != "" {
|
||||
existing.Content = mood.Content
|
||||
}
|
||||
if err := db.Save(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mood = existing
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": mood.ID,
|
||||
"emoji": mood.Emoji,
|
||||
"content": mood.Content,
|
||||
"date": mood.Date,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// saveClassifyReview 保存复盘
|
||||
func saveClassifyReview(db *gorm.DB, userID uint, date string, fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
review := models.Review{
|
||||
UserID: userID,
|
||||
Date: date,
|
||||
}
|
||||
|
||||
if v, ok := fields["keep"].(string); ok {
|
||||
review.Keep = utils.FilterSensitive(v)
|
||||
}
|
||||
if v, ok := fields["problem"].(string); ok {
|
||||
review.Problem = utils.FilterSensitive(v)
|
||||
}
|
||||
if v, ok := fields["try"].(string); ok {
|
||||
review.Try = utils.FilterSensitive(v)
|
||||
}
|
||||
|
||||
// 同一天只允许一条复盘,存在则更新
|
||||
var existing models.Review
|
||||
err := db.Where("user_id = ? AND date = ?", userID, date).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if err := db.Create(&review).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
if review.Keep != "" {
|
||||
existing.Keep = review.Keep
|
||||
}
|
||||
if review.Problem != "" {
|
||||
existing.Problem = review.Problem
|
||||
}
|
||||
if review.Try != "" {
|
||||
existing.Try = review.Try
|
||||
}
|
||||
if err := db.Save(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
review = existing
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": review.ID,
|
||||
"keep": review.Keep,
|
||||
"problem": review.Problem,
|
||||
"try": review.Try,
|
||||
"date": review.Date,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user