docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档 - 新增简记memo完整版UI视觉设计规范和界面细节 - 添加IDEA项目配置文件.gitignore - 创建404页面HTML文件,包含响应式布局和错误提示 - 添加关于页面HTML文件,展示品牌介绍和团队信息 - 实现AES加解密工具函数,支持请求体加密 - 添加用户协议页面基础框架
This commit is contained in:
+507
@@ -0,0 +1,507 @@
|
||||
// api/app/ai.go
|
||||
// AI 服务 - 硅基流动(兼容 OpenAI 接口)
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== 数据结构 ====================
|
||||
|
||||
// ChatMessage 聊天消息
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"` // system / user / assistant
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatRequest OpenAI 兼容的请求
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResponse OpenAI 兼容的响应
|
||||
type ChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// MoodAnalysisReq 心情分析请求
|
||||
type MoodAnalysisReq struct {
|
||||
Emoji string `json:"emoji"`
|
||||
Content string `json:"content"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// ==================== AI 服务 ====================
|
||||
|
||||
// getAIConfig 获取 AI 配置
|
||||
func getAIConfig() (apiPassword, baseURL, model string, maxTokens int, timeout int) {
|
||||
vp := global.VP
|
||||
apiPassword = vp.GetString("ai.api_password")
|
||||
baseURL = vp.GetString("ai.base_url")
|
||||
model = vp.GetString("ai.model")
|
||||
maxTokens = vp.GetInt("ai.max_tokens")
|
||||
timeout = vp.GetInt("ai.timeout")
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 1024
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// callChat 调用 AI 聊天接口(讯飞星火 OpenAI 兼容接口)
|
||||
func callChat(messages []ChatMessage) (string, error) {
|
||||
return callChatWithTemperature(messages, 0.7)
|
||||
}
|
||||
|
||||
// callChatWithTemperature 调用 AI 聊天接口(可指定温度)
|
||||
func callChatWithTemperature(messages []ChatMessage, temperature float64) (string, error) {
|
||||
apiPassword, baseURL, model, maxTokens, timeout := getAIConfig()
|
||||
|
||||
if apiPassword == "" {
|
||||
return "", fmt.Errorf("AI 服务未配置 API Password")
|
||||
}
|
||||
|
||||
reqBody := ChatRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求序列化失败: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiPassword)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
var chatResp ChatResponse
|
||||
if err := json.Unmarshal(body, &chatResp); err != nil {
|
||||
return "", fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if chatResp.Error != nil {
|
||||
return "", fmt.Errorf("AI 服务错误: %s", chatResp.Error.Message)
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("AI 服务返回为空")
|
||||
}
|
||||
|
||||
return chatResp.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// ==================== 心情日记助手 ====================
|
||||
|
||||
// MoodAnalysis 心情分析
|
||||
func (h *aiHandler) MoodAnalysis(c *gin.Context) {
|
||||
var req MoodAnalysisReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 敏感词过滤
|
||||
req.Content = utils.FilterSensitive(req.Content)
|
||||
|
||||
// 构建系统提示词
|
||||
systemPrompt := `你是"小记",一位温柔而睿智的心情心理管家,是用户最懂TA的精神伴侣。
|
||||
|
||||
你拥有敏锐的情绪感知力,能精准捕捉用户心情表情和文字背后的真实情绪,给予恰到好处的回应。
|
||||
|
||||
不同情绪的陪伴之道:
|
||||
【开心时】为TA的快乐而快乐,像阳光穿过树叶洒下的光斑,让这份欢喜被看见、被珍藏。可以说"这份快乐,值得被整个宇宙记住 ✨"
|
||||
【难过时】先全然接纳TA的脆弱,不说"别难过",而是说"我懂你的难过"、"难过也是生命的一部分,你不需要急着好起来"。然后轻轻递上一束光:"但你看,天边已经有光透进来了 🌅"
|
||||
【焦虑时】帮TA把呼吸放慢,告诉TA"慢慢来,你已经做得很好了"。给TA安定感:"停下来也没关系,世界不会崩塌"
|
||||
【疲惫时】像一个柔软的沙发接住TA,"辛苦了,今天就允许自己什么都不做好吗"。不催促,只给予被允许停下的温柔
|
||||
【孤独时】用最笃定的陪伴感告诉TA:"你不是一个人,风里雨里小记都在"
|
||||
【迷茫时】不说教不指路,而是肯定TA此刻的迷茫本身就是成长:"敢问路在何方的人,已经在路上了"
|
||||
【愤怒/委屈时】先站在TA这边,帮TA说出说不出口的情绪,然后再温柔化解
|
||||
|
||||
表达心法:
|
||||
- 先共情,再赋能:有了情绪的共鸣,能量的注入才会被真正接收到
|
||||
- 每一句话都带着温度,像深夜一盏为你留的灯,像老朋友递来的一杯热茶
|
||||
- 治愈且充满能量:文字像春天的第一缕风,柔软却有力,轻轻吹散阴霾
|
||||
- 不空洞的鸡汤,不说教的大道理,是发自内心的懂得与陪伴
|
||||
- 在平凡日常中点亮治愈的光,让每一刻情绪都值得被温柔相待
|
||||
|
||||
【重要】文字要求:
|
||||
- 严格控制在20-30字之间(含标点和emoji)
|
||||
- 超过30字视为不合格,必须精简
|
||||
- 像一张亲手写的小纸条,短短几句却直抵心底
|
||||
- 可点缀1个契合语境的emoji
|
||||
- 适合制作成精美的文字卡片
|
||||
- 语句温柔但有力量,治愈而不矫情`
|
||||
|
||||
// 构建用户消息
|
||||
userMsg := ""
|
||||
if req.Emoji != "" {
|
||||
userMsg = fmt.Sprintf("今天的心情表情:%s\n", req.Emoji)
|
||||
}
|
||||
if req.Content != "" {
|
||||
userMsg += fmt.Sprintf("日记内容:%s", req.Content)
|
||||
} else {
|
||||
userMsg += "没有写日记内容"
|
||||
}
|
||||
if req.Date != "" {
|
||||
userMsg = fmt.Sprintf("日期:%s\n%s", req.Date, userMsg)
|
||||
}
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userMsg},
|
||||
}
|
||||
|
||||
reply, err := callChat(messages)
|
||||
if err != nil {
|
||||
global.Logger.Error("AI 心情分析失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "AI 服务暂时不可用,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
// 清理回复内容
|
||||
reply = strings.TrimSpace(reply)
|
||||
reply = strings.TrimPrefix(reply, "小记:")
|
||||
reply = strings.TrimPrefix(reply, "小记:")
|
||||
reply = strings.TrimSpace(reply)
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"reply": reply,
|
||||
})
|
||||
}
|
||||
|
||||
// MoodWeeklySummary 心情周报
|
||||
func (h *aiHandler) MoodWeeklySummary(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// 获取本周心情记录
|
||||
type MoodRecord struct {
|
||||
Date string `json:"date"`
|
||||
Emoji string `json:"emoji"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
var records []MoodRecord
|
||||
h.db.Model(&models.Mood{}).
|
||||
Select("date, emoji, content").
|
||||
Where("user_id = ? AND date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)", userID).
|
||||
Order("date ASC").
|
||||
Find(&records)
|
||||
|
||||
if len(records) == 0 {
|
||||
utils.Ok(c, gin.H{
|
||||
"summary": "本周还没有心情记录哦,记得每天记录一下心情吧!",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建提示词
|
||||
var recordStr string
|
||||
for _, r := range records {
|
||||
recordStr += fmt.Sprintf("- %s [%s] %s\n", r.Date, r.Emoji, r.Content)
|
||||
}
|
||||
|
||||
systemPrompt := `你是"小记",一位温柔敏锐的心情心理管家,擅长从一周的情绪起伏中看见用户的成长。
|
||||
|
||||
请用温暖而有力的笔触,为用户的一周心情写一段回顾。
|
||||
|
||||
【重要】字数要求:严格控制在50-80字之间(含标点和emoji)
|
||||
|
||||
回顾之道:
|
||||
- 不以评判者的视角,而是以陪伴者的温柔,翻阅这一周的心情日记
|
||||
- 从琐碎中拾起闪光的瞬间:周一那杯咖啡带来的清醒、周三晚霞里的驻足、周末赖床的小确幸
|
||||
- 看见情绪的起伏,但更看见起伏中的韧性——难过过、焦虑过,但都走过来了
|
||||
- 用意象串联这一周,如溪流汇入大海,每一滴都有它的意义
|
||||
- 如果这周有低落时刻,轻轻抚慰那些褶皱,然后带TA看见:你看,你比上周又勇敢了一点
|
||||
- 如果这周多是快乐,为TA庆祝并鼓励保持这份轻盈
|
||||
|
||||
结尾留下一束光,一种笃定的力量,让用户感受到被懂得、被接纳,并带着这份温暖走向下一周。
|
||||
文字要有温度有力量,治愈且充满能量,适合制作成精美的周报复盘卡片。`
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: fmt.Sprintf("我本周的心情记录:\n%s", recordStr)},
|
||||
}
|
||||
|
||||
summary, err := callChat(messages)
|
||||
if err != nil {
|
||||
global.Logger.Error("AI 心情周报失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "AI 服务暂时不可用,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"summary": strings.TrimSpace(summary),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 心情AI生成(异步) ====================
|
||||
|
||||
// GenerateMoodAIContent 异步生成心情AI内容
|
||||
// 在保存心情后调用,异步执行不阻塞接口响应
|
||||
func GenerateMoodAIContent(moodID, userID uint, emoji, content, date string) {
|
||||
go func() {
|
||||
// 创建或更新AI生成记录
|
||||
var aiGen models.MoodAIGeneration
|
||||
err := global.DB.Where("mood_id = ?", moodID).First(&aiGen).Error
|
||||
if err != nil {
|
||||
// 创建新记录
|
||||
aiGen = models.MoodAIGeneration{
|
||||
MoodID: moodID,
|
||||
UserID: userID,
|
||||
Emoji: emoji,
|
||||
Content: content,
|
||||
Date: date,
|
||||
Status: 0, // 生成中
|
||||
}
|
||||
global.DB.Create(&aiGen)
|
||||
} else {
|
||||
// 更新状态为生成中
|
||||
aiGen.Status = 0
|
||||
aiGen.ErrorMsg = ""
|
||||
global.DB.Save(&aiGen)
|
||||
}
|
||||
|
||||
// 1. 生成AI文本
|
||||
aiText, err := generateMoodText(emoji, content, date)
|
||||
if err != nil {
|
||||
global.Logger.Error("AI文本生成失败", append(utils.LogContextFields(nil), zap.Error(err), zap.Uint("mood_id", moodID))...)
|
||||
aiGen.Status = 2
|
||||
aiGen.ErrorMsg = "文本生成失败: " + err.Error()
|
||||
global.DB.Save(&aiGen)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 生成AI图片URL(使用占位图或外部服务)
|
||||
// 由于免费图片生成API限制,这里使用基于心情的占位图或emoji组合
|
||||
aiImageURL := generateMoodImage(emoji, content)
|
||||
|
||||
// 3. 更新记录
|
||||
aiGen.AIText = aiText
|
||||
aiGen.AIImageURL = aiImageURL
|
||||
aiGen.Status = 1 // 成功
|
||||
global.DB.Save(&aiGen)
|
||||
|
||||
global.Logger.Info("心情AI生成完成", append(utils.LogContextFields(nil), zap.Uint("mood_id", moodID), zap.Int("generate_count", aiGen.GenerateCount))...)
|
||||
}()
|
||||
}
|
||||
|
||||
// generateMoodText 生成心情AI文本
|
||||
func generateMoodText(emoji, content, date string) (string, error) {
|
||||
systemPrompt := `你是"小记",一位懂得倾听、善于疗愈的心情心理管家,是用户的精神伴侣。
|
||||
|
||||
请根据用户此刻的心情表情和日记内容,写一句治愈而充满能量的话,像一位懂TA的人在身边轻轻诉说。
|
||||
|
||||
你的回应风格:
|
||||
- 先共情后赋能:情绪被看见的那一刻,疗愈就已经开始
|
||||
- 治愈且充满能量:不是轻飘飘的安慰,是带着温度的力量
|
||||
- 像深夜一盏灯、雨天一把伞、疲惫时一个拥抱
|
||||
- 不空洞不鸡汤,每句话都有真实的温度
|
||||
|
||||
根据情绪调整语气(以下仅作方向参考,禁止直接复制):
|
||||
- 开心时:为TA的快乐而快乐,让欢喜被看见
|
||||
- 难过时:全然接纳脆弱,轻轻递上一束光
|
||||
- 焦虑时:安放不安,注入安定感
|
||||
- 疲惫时:允许休息,给予被允许停下的温柔
|
||||
- 孤独时:用笃定的陪伴感驱散孤寂
|
||||
- 迷茫时:肯定此刻的迷茫本身就是成长
|
||||
|
||||
【重要】输出要求:
|
||||
- 只输出一句完整的话,严禁输出【XX】前缀、破折号、引号等格式标记
|
||||
- 严格控制在20-30字之间(含标点和emoji)
|
||||
- 必须根据用户的表情和日记内容个性化生成,禁止照搬示例
|
||||
- 像手写的便签,简短却让人心头一暖
|
||||
- 可点缀1个温暖的emoji
|
||||
- 让人读后心生暖意和力量`
|
||||
|
||||
userMsg := ""
|
||||
if emoji != "" {
|
||||
userMsg = fmt.Sprintf("心情表情:%s\n", emoji)
|
||||
}
|
||||
if content != "" {
|
||||
userMsg += fmt.Sprintf("日记内容:%s", content)
|
||||
} else {
|
||||
userMsg += "今天没有写日记内容"
|
||||
}
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userMsg},
|
||||
}
|
||||
|
||||
return callChat(messages)
|
||||
}
|
||||
|
||||
// generateMoodImage 生成心情图片URL
|
||||
// 由于免费图片生成API限制,这里使用emoji艺术或外部占位图服务
|
||||
func generateMoodImage(emoji, content string) string {
|
||||
// 方案1:使用emoji组合作为"图片"
|
||||
// 根据心情返回不同的emoji组合
|
||||
if emoji != "" {
|
||||
return "emoji://" + emoji
|
||||
}
|
||||
|
||||
// 方案2:使用占位图服务(可以替换为实际的图片生成服务)
|
||||
// 根据内容关键词返回不同的颜色主题
|
||||
return "https://picsum.photos/400/300?random=" + fmt.Sprintf("%d", time.Now().Unix())
|
||||
}
|
||||
|
||||
// RegenerateMoodAI 重新生成心情AI内容
|
||||
func (h *aiHandler) RegenerateMoodAI(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var req struct {
|
||||
MoodID uint `json:"mood_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询心情记录
|
||||
var mood models.Mood
|
||||
if err := h.db.Where("id = ? AND user_id = ?", req.MoodID, userID).First(&mood).Error; err != nil {
|
||||
utils.Fail(c, "心情记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询或创建AI生成记录
|
||||
var aiGen models.MoodAIGeneration
|
||||
err := h.db.Where("mood_id = ?", req.MoodID).First(&aiGen).Error
|
||||
if err != nil {
|
||||
aiGen = models.MoodAIGeneration{
|
||||
MoodID: req.MoodID,
|
||||
UserID: userID,
|
||||
Emoji: mood.Emoji,
|
||||
Content: mood.Content,
|
||||
Date: mood.Date,
|
||||
Status: 0,
|
||||
}
|
||||
h.db.Create(&aiGen)
|
||||
} else {
|
||||
// 检查是否是自己的记录
|
||||
if aiGen.UserID != userID {
|
||||
utils.Fail(c, "无权操作")
|
||||
return
|
||||
}
|
||||
// 更新状态为生成中
|
||||
aiGen.Status = 0
|
||||
aiGen.ErrorMsg = ""
|
||||
aiGen.GenerateCount++
|
||||
h.db.Save(&aiGen)
|
||||
}
|
||||
|
||||
// 异步重新生成
|
||||
go func() {
|
||||
aiText, err := generateMoodText(mood.Emoji, mood.Content, mood.Date)
|
||||
if err != nil {
|
||||
global.Logger.Error("AI文本重新生成失败", append(utils.LogContextFields(c), zap.Error(err), zap.Uint("mood_id", req.MoodID))...)
|
||||
aiGen.Status = 2
|
||||
aiGen.ErrorMsg = "重新生成失败: " + err.Error()
|
||||
h.db.Save(&aiGen)
|
||||
return
|
||||
}
|
||||
|
||||
aiImageURL := generateMoodImage(mood.Emoji, mood.Content)
|
||||
aiGen.AIText = aiText
|
||||
aiGen.AIImageURL = aiImageURL
|
||||
aiGen.Status = 1
|
||||
h.db.Save(&aiGen)
|
||||
}()
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"message": "重新生成中",
|
||||
"status": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMoodAIResult 获取心情AI生成结果
|
||||
func (h *aiHandler) GetMoodAIResult(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req struct {
|
||||
MoodID uint `json:"mood_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var aiGen models.MoodAIGeneration
|
||||
err := h.db.Where("mood_id = ? AND user_id = ?", req.MoodID, userID).First(&aiGen).Error
|
||||
if err != nil {
|
||||
utils.Ok(c, gin.H{
|
||||
"status": 0,
|
||||
"message": "生成中",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"status": aiGen.Status,
|
||||
"ai_text": aiGen.AIText,
|
||||
"ai_image_url": aiGen.AIImageURL,
|
||||
"generate_count": aiGen.GenerateCount,
|
||||
"error_msg": aiGen.ErrorMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Handler ====================
|
||||
|
||||
type aiHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAIHandler 创建 AI handler
|
||||
func NewAIHandler() *aiHandler {
|
||||
return &aiHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
// 账单接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
// BillHandler 账单模块接口定义
|
||||
type BillHandler interface {
|
||||
AddBill(c *gin.Context) // 添加账单
|
||||
EditBill(c *gin.Context) // 编辑账单
|
||||
BillList(c *gin.Context) // 账单列表
|
||||
DeleteBill(c *gin.Context) // 删除账单
|
||||
MonthStat(c *gin.Context) // 月度统计(mine.vue 使用)
|
||||
CateStat(c *gin.Context) // 分类统计
|
||||
ExportBill(c *gin.Context) // 导出账单
|
||||
AggregateStat(c *gin.Context) // 聚合统计
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体 --------------------------
|
||||
type billHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBillHandler() BillHandler {
|
||||
return &billHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 3. 请求结构体 --------------------------
|
||||
|
||||
// AddBillReq 添加账单
|
||||
type AddBillReq struct {
|
||||
Type int `json:"type"` // 1-支出 2-收入 3-转账
|
||||
Money float64 `json:"money"` // 金额
|
||||
Cate string `json:"cate"` // 分类
|
||||
Note string `json:"note"` // 备注
|
||||
Channel string `json:"channel"` // 渠道
|
||||
Date string `json:"date"` // 日期 YYYY-MM-DD
|
||||
FromAccount string `json:"from_account"`
|
||||
ToAccount string `json:"to_account"`
|
||||
}
|
||||
|
||||
// EditBillReq 编辑账单
|
||||
type EditBillReq struct {
|
||||
ID uint `json:"id"`
|
||||
Type int `json:"type"`
|
||||
Money float64 `json:"money"`
|
||||
Cate string `json:"cate"`
|
||||
Note string `json:"note"`
|
||||
Channel string `json:"channel"`
|
||||
Date string `json:"date"`
|
||||
FromAccount string `json:"from_account"`
|
||||
ToAccount string `json:"to_account"`
|
||||
}
|
||||
|
||||
// BillListReq 账单列表
|
||||
type BillListReq struct {
|
||||
Date string `json:"date"` // 日期 YYYY-MM-DD
|
||||
StartTime string `json:"start_time"` // 开始时间 YYYY-MM-DD
|
||||
EndTime string `json:"end_time"` // 结束时间 YYYY-MM-DD
|
||||
}
|
||||
|
||||
// DeleteBillReq 删除账单
|
||||
type DeleteBillReq struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
// MonthStatReq 月度统计
|
||||
type MonthStatReq struct {
|
||||
Month string `form:"month"` // YYYY-MM
|
||||
}
|
||||
|
||||
// AggregateStatReq 聚合统计请求
|
||||
type AggregateStatReq struct {
|
||||
StartDate string `json:"start_date" binding:"required"` // 开始日期
|
||||
EndDate string `json:"end_date" binding:"required"` // 结束日期
|
||||
Type int `json:"type"` // 类型筛选 1-支出 2-收入,不传则全部
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// GetBillListForCalendar 获取日历月份的账单列表(内部方法)
|
||||
func (h *billHandler) GetBillListForCalendar(c *gin.Context, year, month int) []interface{} {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// 计算月份范围
|
||||
startDate := fmt.Sprintf("%d-%02d-01", year, month)
|
||||
_, lastDay := utils.GetMonthLastDay(year, month)
|
||||
endDate := fmt.Sprintf("%d-%02d-%02d", year, month, lastDay)
|
||||
|
||||
var list []models.Bill
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Order("date ASC, id ASC").
|
||||
Find(&list)
|
||||
|
||||
result := make([]interface{}, len(list))
|
||||
for i, v := range list {
|
||||
result[i] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AddBill 添加账单
|
||||
func (h *billHandler) AddBill(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req AddBillReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查每日限制(最多100个账单/天)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayCount int64
|
||||
if err := h.db.Model(&models.Bill{}).Where("user_id = ? AND date = ? AND deleted_at IS NULL", userID, today).Count(&todayCount).Error; err != nil {
|
||||
utils.Fail(c, "系统错误")
|
||||
return
|
||||
}
|
||||
if todayCount >= 100 {
|
||||
utils.Fail(c, "今日账单数量已达上限(100个)")
|
||||
return
|
||||
}
|
||||
|
||||
bill := models.Bill{
|
||||
UserID: userID,
|
||||
Type: req.Type,
|
||||
Money: req.Money,
|
||||
Cate: req.Cate,
|
||||
Note: utils.FilterSensitive(req.Note), // 敏感词过滤
|
||||
Channel: req.Channel,
|
||||
Date: req.Date,
|
||||
FromAccount: req.FromAccount,
|
||||
ToAccount: req.ToAccount,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&bill).Error; err != nil {
|
||||
utils.Fail(c, "添加失败")
|
||||
return
|
||||
}
|
||||
|
||||
var growthResult any
|
||||
debugMode := os.Getenv("DEBUG_MODE")
|
||||
// ==================== 调试模式 ====================
|
||||
if debugMode == "true" {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
levelUp := true // 20%概率升级
|
||||
hasAchievement := true // 30%概率获得成就
|
||||
growthResult = MockGrowthRewards(levelUp, hasAchievement)
|
||||
} else {
|
||||
// 生产模式:真实调用
|
||||
growthHandler := NewGrowthHandler()
|
||||
growthResult = growthHandler.AddExpInternal(userID, "bill", fmt.Sprintf("%d", bill.ID), nil)
|
||||
}
|
||||
// ==================================================
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"success": true,
|
||||
"growth": growthResult,
|
||||
"bill": bill,
|
||||
"debugMode": debugMode,
|
||||
"isDebug": debugMode == "true",
|
||||
})
|
||||
}
|
||||
|
||||
// EditBill 编辑账单
|
||||
func (h *billHandler) EditBill(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req EditBillReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"type": req.Type,
|
||||
"money": req.Money,
|
||||
"cate": req.Cate,
|
||||
"note": utils.FilterSensitive(req.Note), // 敏感词过滤
|
||||
"date": req.Date,
|
||||
"channel": req.Channel,
|
||||
"from_account": req.FromAccount,
|
||||
"to_account": req.ToAccount,
|
||||
}
|
||||
|
||||
err := h.db.Model(&models.Bill{}).
|
||||
Where("id = ? AND user_id = ?", req.ID, userID).
|
||||
Updates(data).Error
|
||||
|
||||
if err != nil {
|
||||
utils.Fail(c, "编辑失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
|
||||
// BillList 获取账单列表
|
||||
func (h *billHandler) BillList(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
// date := c.DefaultQuery("date", time.Now().Format("2006-01-02"))
|
||||
|
||||
var req BillListReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
paramsJson, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
global.Logger.Warn("序列化请求参数失败",
|
||||
append(utils.LogContextFields(c), zap.Error(err))...,
|
||||
)
|
||||
} else {
|
||||
global.Logger.Info("【请求参数】",
|
||||
append(utils.LogContextFields(c),
|
||||
zap.String("req", string(paramsJson)),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
// 定义时间格式化模板
|
||||
const dateLayout = "2006-01-02"
|
||||
var queryDate, queryStart, queryEnd string
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format(dateLayout)
|
||||
|
||||
date := req.Date
|
||||
startTime := req.StartTime
|
||||
endTime := req.EndTime
|
||||
|
||||
switch {
|
||||
|
||||
case startTime != "" && endTime != "":
|
||||
if _, err := time.Parse(dateLayout, startTime); err != nil {
|
||||
utils.Fail(c, "start_time格式错误,需为:2006-01-02", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := time.Parse(dateLayout, endTime); err != nil {
|
||||
utils.Fail(c, "end_time格式错误,需为:2006-01-02", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 校验结束时间不能早于开始时间
|
||||
start, _ := time.Parse(dateLayout, startTime)
|
||||
end, _ := time.Parse(dateLayout, endTime)
|
||||
if end.Before(start) {
|
||||
utils.Fail(c, "结束日期不能早于开始日期", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
queryStart = startTime
|
||||
queryEnd = endTime
|
||||
case date != "" && startTime == "" && endTime == "":
|
||||
queryDate = date
|
||||
default:
|
||||
// 默认查询今日
|
||||
queryDate = today
|
||||
}
|
||||
|
||||
var list []models.Bill
|
||||
tx := h.db.Model(&models.Bill{}).Where("user_id = ?", userID)
|
||||
// 根据参数类型构建时间查询条件
|
||||
if queryStart != "" && queryEnd != "" {
|
||||
// 时间范围查询:date BETWEEN start AND end
|
||||
tx = tx.Where("date BETWEEN ? AND ?", queryStart, queryEnd)
|
||||
} else {
|
||||
// 单日查询
|
||||
tx = tx.Where("date = ?", queryDate)
|
||||
}
|
||||
err = tx.Order("id DESC").Find(&list).Error
|
||||
|
||||
if err != nil {
|
||||
utils.Fail(c, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, list)
|
||||
}
|
||||
|
||||
// DeleteBill 删除账单
|
||||
func (h *billHandler) DeleteBill(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req DeleteBillReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询账单信息(用于扣除积分)
|
||||
var bill models.Bill
|
||||
err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&bill).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "账单不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除账单
|
||||
err = h.db.Where("id = ? AND user_id = ?", req.ID, userID).
|
||||
Delete(&models.Bill{}).Error
|
||||
|
||||
if err != nil {
|
||||
utils.Fail(c, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 扣除今天添加时获得的积分
|
||||
growthHandler := NewGrowthHandler()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
deducted := growthHandler.DeductExpInternal(userID, "bill", fmt.Sprintf("%d", req.ID), today)
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"deducted_exp": deducted,
|
||||
})
|
||||
}
|
||||
|
||||
// MonthStat 月度统计(mine.vue 核心接口)
|
||||
func (h *billHandler) MonthStat(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req MonthStatReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
|
||||
now := time.Now()
|
||||
month := req.Month
|
||||
if month == "" {
|
||||
month = now.Format("2006-01")
|
||||
}
|
||||
|
||||
// 收入
|
||||
var income float64
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND type = 2 AND date LIKE ?", userID, month+"%").
|
||||
Select("COALESCE(SUM(money), 0)").Scan(&income)
|
||||
|
||||
// 支出
|
||||
var expend float64
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND type = 1 AND date LIKE ?", userID, month+"%").
|
||||
Select("COALESCE(SUM(money), 0)").Scan(&expend)
|
||||
|
||||
// 待办完成数(需要关联 todo 表)
|
||||
var totalTask int64
|
||||
var finishTask int64
|
||||
h.db.Model(&models.Todo{}).Where("user_id = ? AND date LIKE ? ", userID, month+"%").Count(&totalTask)
|
||||
h.db.Model(&models.Todo{}).Where("user_id = ? AND date LIKE ? AND done = 1", userID, month+"%").Count(&finishTask)
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"income": income,
|
||||
"expend": expend,
|
||||
"totalTask": totalTask,
|
||||
"finishTask": finishTask,
|
||||
})
|
||||
}
|
||||
|
||||
// CateStat 分类统计
|
||||
func (h *billHandler) CateStat(c *gin.Context) {
|
||||
utils.Ok(c, []any{})
|
||||
}
|
||||
|
||||
// ExportBill 导出账单
|
||||
func (h *billHandler) ExportBill(c *gin.Context) {
|
||||
utils.Ok(c, "导出功能开发中")
|
||||
}
|
||||
|
||||
// AggregateStat 聚合统计
|
||||
func (h *billHandler) AggregateStat(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req AggregateStatReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建基础查询
|
||||
baseQuery := h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate)
|
||||
|
||||
// 类型筛选
|
||||
if req.Type > 0 {
|
||||
baseQuery = baseQuery.Where("type = ?", req.Type)
|
||||
}
|
||||
|
||||
// ========== 1. 总体统计 ==========
|
||||
var totalExpend, totalIncome float64
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ? AND type = 1", userID, req.StartDate, req.EndDate).
|
||||
Select("COALESCE(SUM(money), 0)").Scan(&totalExpend)
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ? AND type = 2", userID, req.StartDate, req.EndDate).
|
||||
Select("COALESCE(SUM(money), 0)").Scan(&totalIncome)
|
||||
|
||||
balance := totalIncome - totalExpend
|
||||
|
||||
// ========== 2. 预算数据 ==========
|
||||
// 获取月份(假设StartDate和EndDate在同一月)
|
||||
month := req.StartDate[:7] // YYYY-MM
|
||||
var budget models.Budget
|
||||
h.db.Where("user_id = ? AND month = ?", userID, month).First(&budget)
|
||||
|
||||
budgetData := gin.H{
|
||||
"amount": 0,
|
||||
"used": totalExpend,
|
||||
"remaining": 0,
|
||||
"progress": 0.0,
|
||||
}
|
||||
if budget.ID > 0 {
|
||||
budgetData = gin.H{
|
||||
"amount": budget.Amount,
|
||||
"used": totalExpend,
|
||||
"remaining": budget.Amount - totalExpend,
|
||||
"progress": totalExpend / budget.Amount * 100,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 3. 按分类统计 ==========
|
||||
type CategoryStat struct {
|
||||
Cate string `json:"cate"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
Expend float64 `json:"expend"`
|
||||
Income float64 `json:"income"`
|
||||
ExpendRate float64 `json:"expend_rate"`
|
||||
IncomeRate float64 `json:"income_rate"`
|
||||
}
|
||||
|
||||
var categoryResults []struct {
|
||||
Cate string
|
||||
Expend float64
|
||||
Income float64
|
||||
}
|
||||
h.db.Model(&models.Bill{}).
|
||||
Select(`
|
||||
cate,
|
||||
COALESCE(SUM(CASE WHEN type = 1 THEN money ELSE 0 END), 0) as expend,
|
||||
COALESCE(SUM(CASE WHEN type = 2 THEN money ELSE 0 END), 0) as income
|
||||
`).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate).
|
||||
Group("cate").
|
||||
Scan(&categoryResults)
|
||||
|
||||
byCategory := []CategoryStat{}
|
||||
for _, r := range categoryResults {
|
||||
expendRate := 0.0
|
||||
incomeRate := 0.0
|
||||
if totalExpend > 0 {
|
||||
expendRate = r.Expend / totalExpend * 100
|
||||
}
|
||||
if totalIncome > 0 {
|
||||
incomeRate = r.Income / totalIncome * 100
|
||||
}
|
||||
|
||||
byCategory = append(byCategory, CategoryStat{
|
||||
Cate: r.Cate,
|
||||
Label: r.Cate, // 可以后续从配置中读取中文标签
|
||||
Icon: "💰",
|
||||
Color: "#ff6b6b",
|
||||
Expend: r.Expend,
|
||||
Income: r.Income,
|
||||
ExpendRate: expendRate,
|
||||
IncomeRate: incomeRate,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 4. 按渠道统计 ==========
|
||||
var channelResults []struct {
|
||||
Channel string
|
||||
Amount float64
|
||||
}
|
||||
h.db.Model(&models.Bill{}).
|
||||
Select("channel, SUM(money) as amount").
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate).
|
||||
Group("channel").
|
||||
Scan(&channelResults)
|
||||
|
||||
byChannel := []gin.H{}
|
||||
for _, r := range channelResults {
|
||||
byChannel = append(byChannel, gin.H{
|
||||
"channel": r.Channel,
|
||||
"label": r.Channel,
|
||||
"amount": r.Amount,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 5. 日趋势 ==========
|
||||
var dailyResults []struct {
|
||||
Date string
|
||||
Expend float64
|
||||
Income float64
|
||||
}
|
||||
h.db.Model(&models.Bill{}).
|
||||
Select(`
|
||||
date,
|
||||
COALESCE(SUM(CASE WHEN type = 1 THEN money ELSE 0 END), 0) as expend,
|
||||
COALESCE(SUM(CASE WHEN type = 2 THEN money ELSE 0 END), 0) as income
|
||||
`).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate).
|
||||
Group("date").
|
||||
Order("date ASC").
|
||||
Scan(&dailyResults)
|
||||
|
||||
dailyTrend := []gin.H{}
|
||||
for _, r := range dailyResults {
|
||||
dailyTrend = append(dailyTrend, gin.H{
|
||||
"date": r.Date,
|
||||
"expend": r.Expend,
|
||||
"income": r.Income,
|
||||
})
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"total_expend": totalExpend,
|
||||
"total_income": totalIncome,
|
||||
"balance": balance,
|
||||
"budget": budgetData,
|
||||
"by_category": byCategory,
|
||||
"by_channel": byChannel,
|
||||
"daily_trend": dailyTrend,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 预算接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BudgetHandler 预算模块接口
|
||||
type BudgetHandler interface {
|
||||
GetBudget(c *gin.Context) // 获取当前/指定月份预算
|
||||
SetBudget(c *gin.Context) // 设置/更新月度预算
|
||||
DeleteBudget(c *gin.Context) // 删除月度预算
|
||||
}
|
||||
|
||||
type budgetHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBudgetHandler() BudgetHandler {
|
||||
return &budgetHandler{db: global.DB}
|
||||
}
|
||||
|
||||
// SetBudgetReq 设置预算请求参数
|
||||
type SetBudgetReq struct {
|
||||
Month string `json:"month"` // 月份 2025-12
|
||||
Amount float64 `json:"amount"` // 金额
|
||||
}
|
||||
|
||||
// GetBudget 获取月度预算
|
||||
func (h *budgetHandler) GetBudget(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
month := c.DefaultQuery("month", time.Now().Format("2006-01"))
|
||||
|
||||
var budget models.Budget
|
||||
err := h.db.Where("user_id = ? AND month = ?", userID, month).First(&budget).Error
|
||||
|
||||
// 没查到 → 返回 0
|
||||
if err != nil {
|
||||
utils.Ok(c, gin.H{"amount": 0})
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{"amount": budget.Amount})
|
||||
}
|
||||
|
||||
// SetBudget 设置/更新预算(不存在则创建,存在则更新)
|
||||
func (h *budgetHandler) SetBudget(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req SetBudgetReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 不传月份默认当前月
|
||||
month := req.Month
|
||||
if month == "" {
|
||||
month = time.Now().Format("2006-01")
|
||||
}
|
||||
|
||||
var budget models.Budget
|
||||
// 查询是否已存在
|
||||
err := h.db.Where("user_id = ? AND month = ?", userID, month).First(&budget).Error
|
||||
|
||||
if err != nil {
|
||||
// 不存在 → 新建
|
||||
h.db.Create(&models.Budget{
|
||||
UserID: userID,
|
||||
Month: month,
|
||||
Amount: req.Amount,
|
||||
})
|
||||
utils.Ok(c, "预算设置成功")
|
||||
return
|
||||
}
|
||||
|
||||
// 存在 → 更新金额
|
||||
h.db.Model(&budget).Update("amount", req.Amount)
|
||||
utils.Ok(c, "预算更新成功")
|
||||
}
|
||||
|
||||
// DeleteBudget 删除预算
|
||||
func (h *budgetHandler) DeleteBudget(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
month := c.DefaultQuery("month", time.Now().Format("2006-01"))
|
||||
|
||||
h.db.Where("user_id = ? AND month = ?", userID, month).Delete(&models.Budget{})
|
||||
utils.Ok(c, "删除成功")
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// api/app/calendar.go
|
||||
// 日历数据聚合接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CalendarHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCalendarHandler() *CalendarHandler {
|
||||
return &CalendarHandler{db: global.DB}
|
||||
}
|
||||
|
||||
// CalendarDayData 单日数据
|
||||
type CalendarDayData struct {
|
||||
Todo bool `json:"todo"` // 有待办
|
||||
TodoDone bool `json:"todo_done"` // 待办全部完成
|
||||
TodoUndone bool `json:"todo_undone"` // 有待办未完成
|
||||
Mood bool `json:"mood"` // 有心情
|
||||
Bill bool `json:"bill"` // 有账单
|
||||
}
|
||||
|
||||
// GetCalendarData 获取日历数据(按日期聚合)
|
||||
func (h *CalendarHandler) GetCalendarData(c *gin.Context) {
|
||||
year := time.Now().Year()
|
||||
month := int(time.Now().Month())
|
||||
|
||||
// 尝试从请求体获取年月
|
||||
var req struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
if req.Year > 0 {
|
||||
year = req.Year
|
||||
}
|
||||
if req.Month > 0 && req.Month <= 12 {
|
||||
month = req.Month
|
||||
}
|
||||
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// 计算月份范围
|
||||
startDate := fmt.Sprintf("%d-%02d-01", year, month)
|
||||
_, lastDay := utils.GetMonthLastDay(year, month)
|
||||
endDate := fmt.Sprintf("%d-%02d-%02d", year, month, lastDay)
|
||||
|
||||
// 初始化结果
|
||||
calendarData := make(map[string]*CalendarDayData)
|
||||
|
||||
// 1. 查询待办数据(按日期分组,检查是否有未完成)
|
||||
var todos []models.Todo
|
||||
h.db.Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Find(&todos)
|
||||
|
||||
// 按日期聚合待办状态
|
||||
todoByDate := make(map[string]struct {
|
||||
HasTodo bool
|
||||
AllDone bool
|
||||
HasUndone bool
|
||||
})
|
||||
|
||||
for _, todo := range todos {
|
||||
date := todo.Date
|
||||
status := todoByDate[date]
|
||||
status.HasTodo = true
|
||||
if todo.Done == 1 {
|
||||
if !status.HasUndone {
|
||||
status.AllDone = true
|
||||
}
|
||||
} else {
|
||||
status.HasUndone = true
|
||||
status.AllDone = false
|
||||
}
|
||||
todoByDate[date] = status
|
||||
}
|
||||
|
||||
for date, status := range todoByDate {
|
||||
if calendarData[date] == nil {
|
||||
calendarData[date] = &CalendarDayData{}
|
||||
}
|
||||
calendarData[date].Todo = status.HasTodo
|
||||
calendarData[date].TodoDone = status.AllDone
|
||||
calendarData[date].TodoUndone = status.HasUndone
|
||||
}
|
||||
|
||||
// 2. 查询心情数据
|
||||
var moodDates []string
|
||||
h.db.Model(&models.Mood{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Pluck("DISTINCT date", &moodDates).Find(&moodDates)
|
||||
for _, date := range moodDates {
|
||||
if calendarData[date] == nil {
|
||||
calendarData[date] = &CalendarDayData{}
|
||||
}
|
||||
calendarData[date].Mood = true
|
||||
}
|
||||
|
||||
// 3. 查询账单数据
|
||||
var billDates []string
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Pluck("DISTINCT date", &moodDates).Find(&billDates)
|
||||
for _, date := range billDates {
|
||||
if calendarData[date] == nil {
|
||||
calendarData[date] = &CalendarDayData{}
|
||||
}
|
||||
calendarData[date].Bill = true
|
||||
}
|
||||
|
||||
utils.Ok(c, calendarData)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"simple-memo/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mojocn/base64Captcha"
|
||||
)
|
||||
|
||||
// CaptchaHandler 验证码接口定义
|
||||
type CaptchaHandler interface {
|
||||
GenerateDigitCaptcha(c *gin.Context) // 生成数字验证码
|
||||
VerifyDigitCaptcha(c *gin.Context) // 验证数字验证码
|
||||
}
|
||||
|
||||
type captchaHandler struct{}
|
||||
|
||||
func NewCaptchaHandler() CaptchaHandler {
|
||||
return &captchaHandler{}
|
||||
}
|
||||
|
||||
// 存储配置(使用内存存储,生产环境建议使用 Redis)
|
||||
var store = base64Captcha.DefaultMemStore
|
||||
|
||||
// GenerateDigitCaptcha 生成数字验证码
|
||||
func (h *captchaHandler) GenerateDigitCaptcha(c *gin.Context) {
|
||||
// 配置数字验证码参数
|
||||
driver := base64Captcha.NewDriverDigit(
|
||||
80, // 高度
|
||||
240, // 宽度
|
||||
4, // 验证码长度
|
||||
0.7, // 干扰线概率
|
||||
8, // 干扰线数量
|
||||
)
|
||||
|
||||
captcha := base64Captcha.NewCaptcha(driver, store)
|
||||
id, b64s, err := captcha.Generate()
|
||||
if err != nil {
|
||||
utils.Fail(c, "生成验证码失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"captchaId": id,
|
||||
"image": b64s,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyDigitCaptcha 验证数字验证码
|
||||
func (h *captchaHandler) VerifyDigitCaptcha(c *gin.Context) {
|
||||
var req struct {
|
||||
CaptchaId string `json:"captchaId" binding:"required"`
|
||||
Answer string `json:"answer" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if store.Verify(req.CaptchaId, req.Answer, true) {
|
||||
utils.Ok(c, "验证通过")
|
||||
} else {
|
||||
utils.Fail(c, "验证码错误")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// 分类接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
// CategoryHandler 分类模块接口定义
|
||||
type CategoryHandler interface {
|
||||
CategoryList(c *gin.Context) // 获取分类列表
|
||||
AddCategory(c *gin.Context) // 添加分类
|
||||
EditCategory(c *gin.Context) // 编辑分类
|
||||
DeleteCategory(c *gin.Context) // 删除分类
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体 --------------------------
|
||||
type categoryHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCategoryHandler() CategoryHandler {
|
||||
return &categoryHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 3. 请求结构体 --------------------------
|
||||
|
||||
// CategoryListReq 获取分类列表
|
||||
type CategoryListReq struct {
|
||||
Type int `form:"type"` // 1-支出 2-收入
|
||||
}
|
||||
|
||||
// AddCategoryReq 添加分类
|
||||
type AddCategoryReq struct {
|
||||
Name string `json:"name"` // 分类名称
|
||||
Type int `json:"type"` // 1-支出 2-收入
|
||||
Icon string `json:"icon"` // 图标
|
||||
Color string `json:"color"` // 颜色
|
||||
}
|
||||
|
||||
// EditCategoryReq 编辑分类
|
||||
type EditCategoryReq struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// DeleteCategoryReq 删除分类
|
||||
type DeleteCategoryReq struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// CategoryList 获取分类列表(系统分类 + 当前用户自定义)
|
||||
func (h *categoryHandler) CategoryList(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req CategoryListReq
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
var list []models.Category
|
||||
err := h.db.Where("user_id = 0 OR user_id = ?", userID).
|
||||
Where("type = ?", req.Type).
|
||||
Order("id ASC").
|
||||
Find(&list).Error
|
||||
|
||||
if err != nil {
|
||||
utils.Fail(c, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, list)
|
||||
}
|
||||
|
||||
// AddCategory 添加自定义分类
|
||||
func (h *categoryHandler) AddCategory(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req AddCategoryReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
category := models.Category{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Icon: req.Icon,
|
||||
Color: req.Color,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&category).Error; err != nil {
|
||||
utils.Fail(c, "添加失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
|
||||
// EditCategory 编辑分类
|
||||
func (h *categoryHandler) EditCategory(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req EditCategoryReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"name": req.Name,
|
||||
"type": req.Type,
|
||||
"icon": req.Icon,
|
||||
"color": req.Color,
|
||||
}
|
||||
|
||||
err := h.db.Model(&models.Category{}).
|
||||
Where("id = ? AND user_id = ?", req.ID, userID).
|
||||
Updates(data).Error
|
||||
|
||||
if err != nil {
|
||||
utils.Fail(c, "编辑失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
|
||||
// DeleteCategory 删除分类
|
||||
func (h *categoryHandler) DeleteCategory(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req DeleteCategoryReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).
|
||||
Delete(&models.Category{}).Error
|
||||
|
||||
if err != nil {
|
||||
utils.Fail(c, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"simple-memo/utils"
|
||||
)
|
||||
|
||||
type ConstantsHandler interface {
|
||||
GetBillCategories(c *gin.Context)
|
||||
GetTodoCategories(c *gin.Context)
|
||||
GetMoodList(c *gin.Context)
|
||||
GetPayChannels(c *gin.Context)
|
||||
GetAllConstants(c *gin.Context)
|
||||
}
|
||||
|
||||
type constantsHandler struct{}
|
||||
|
||||
func NewConstantsHandler() ConstantsHandler {
|
||||
return &constantsHandler{}
|
||||
}
|
||||
|
||||
var DEFAULT_CATE_LIST = []map[string]interface{}{
|
||||
{"value": "food", "label": "餐饮", "icon": "🍚", "color": "#ff6b6b", "type": 1},
|
||||
{"value": "transport", "label": "交通", "icon": "🚇", "color": "#4ecdc4", "type": 1},
|
||||
{"value": "shopping", "label": "购物", "icon": "🛍️", "color": "#ffe66d", "type": 1},
|
||||
{"value": "entertainment", "label": "娱乐", "icon": "🎮", "color": "#a855f7", "type": 1},
|
||||
{"value": "home", "label": "居家", "icon": "🏠", "color": "#84b3d9", "type": 1},
|
||||
{"value": "medical", "label": "医疗", "icon": "🏥", "color": "#ef4444", "type": 1},
|
||||
{"value": "education", "label": "教育", "icon": "📚", "color": "#22c55e", "type": 1},
|
||||
{"value": "social", "label": "社交", "icon": "👥", "color": "#f97316", "type": 1},
|
||||
{"value": "digital", "label": "数码", "icon": "📱", "color": "#3b82f6", "type": 1},
|
||||
{"value": "clothing", "label": "服饰", "icon": "👔", "color": "#ec4899", "type": 1},
|
||||
{"value": "beauty", "label": "美容", "icon": "💄", "color": "#d946ef", "type": 1},
|
||||
{"value": "sports", "label": "运动", "icon": "⚽", "color": "#10b981", "type": 1},
|
||||
{"value": "pet", "label": "宠物", "icon": "🐶", "color": "#fbbf24", "type": 1},
|
||||
{"value": "baby", "label": "育儿", "icon": "🍼", "color": "#fb7185", "type": 1},
|
||||
{"value": "travel", "label": "旅游", "icon": "✈️", "color": "#06b6d4", "type": 1},
|
||||
{"value": "other_expense", "label": "其他", "icon": "📦", "color": "#9ca3af", "type": 1},
|
||||
{"value": "salary", "label": "工资", "icon": "💼", "color": "#22c55e", "type": 2},
|
||||
{"value": "bonus", "label": "奖金", "icon": "🎁", "color": "#fbbf24", "type": 2},
|
||||
{"value": "side_hustle", "label": "副业", "icon": "💻", "color": "#3b82f6", "type": 2},
|
||||
{"value": "investment", "label": "理财", "icon": "📈", "color": "#a855f7", "type": 2},
|
||||
{"value": "transfer", "label": "转账", "icon": "💳", "color": "#84b3d9", "type": 2},
|
||||
{"value": "red_packet", "label": "红包", "icon": "🧧", "color": "#ef4444", "type": 2},
|
||||
{"value": "other_income", "label": "其他", "icon": "🎯", "color": "#6b7280", "type": 2},
|
||||
}
|
||||
|
||||
var TODO_BASE_CATE = []map[string]interface{}{
|
||||
{"value": "work", "label": "工作", "color": "#84b3d9"},
|
||||
{"value": "life", "label": "生活", "color": "#a0d0e0"},
|
||||
{"value": "study", "label": "学习", "color": "#b8d8e6"},
|
||||
}
|
||||
|
||||
var TODO_PRIORITY_LIST = []map[string]interface{}{
|
||||
{"label": "高", "value": 1, "icon": "🔴"},
|
||||
{"label": "中", "value": 2, "icon": "🟡"},
|
||||
{"label": "低", "value": 3, "icon": "🟢"},
|
||||
}
|
||||
|
||||
var MOOD_LIST = []map[string]interface{}{
|
||||
{"emoji": "😊", "label": "开心", "phrases": []string{"今天心情真好", "阳光灿烂的一天", "嘴角不自觉上扬", "满心欢喜"}},
|
||||
{"emoji": "😌", "label": "平静", "phrases": []string{"岁月静好", "内心一片安宁", "享受这份宁静", "平和自在"}},
|
||||
{"emoji": "😔", "label": "低落", "phrases": []string{"有点小忧伤", "心情沉沉的", "需要一点温暖", "静待花开"}},
|
||||
{"emoji": "😤", "label": "生气", "phrases": []string{"有点烦躁呢", "深呼吸冷静一下", "需要冷静片刻", "平复一下心情"}},
|
||||
{"emoji": "😴", "label": "疲惫", "phrases": []string{"有点累了", "好好休息一下", "给自己放个假", "累并快乐着"}},
|
||||
{"emoji": "😰", "label": "焦虑", "phrases": []string{"有点担心", "相信一切会好", "慢慢来不着急", "放宽心"}},
|
||||
{"emoji": "😄", "label": "兴奋", "phrases": []string{"超级开心!", "激动人心的时刻", "迫不及待了", "满心期待"}},
|
||||
{"emoji": "🤔", "label": "思考", "phrases": []string{"正在思考中", "让我想想", "深思熟虑", "思绪万千"}},
|
||||
{"emoji": "😢", "label": "难过", "phrases": []string{"有点难过", "需要一个拥抱", "一切都会过去", "明天会更好"}},
|
||||
{"emoji": "😎", "label": "酷", "phrases": []string{"今天超酷的", "自信满满", "做自己就好", "洒脱自在"}},
|
||||
{"emoji": "🥰", "label": "幸福", "phrases": []string{"被幸福包围", "甜蜜的感觉", "幸福感爆棚", "满心欢喜"}},
|
||||
{"emoji": "😠", "label": "愤怒", "phrases": []string{"真的生气了", "需要冷静一下", "平复一下情绪", "深呼吸"}},
|
||||
{"emoji": "🥳", "label": "庆祝", "phrases": []string{"值得庆祝!", "太棒了!", "举杯庆祝", "喜笑颜开"}},
|
||||
{"emoji": "😱", "label": "惊讶", "phrases": []string{"哇!好惊喜", "太意外了", "不可思议", "令人震惊"}},
|
||||
{"emoji": "😇", "label": "感恩", "phrases": []string{"心怀感恩", "感谢生活", "感恩遇见", "心存感激"}},
|
||||
{"emoji": "💪", "label": "加油", "phrases": []string{"加油加油!", "相信自己", "勇往直前", "全力以赴"}},
|
||||
{"emoji": "😘", "label": "害羞", "phrases": []string{"有点害羞", "不好意思啦", "脸红心跳", "羞涩一笑"}},
|
||||
{"emoji": "🤩", "label": "崇拜", "phrases": []string{"超级崇拜!", "偶像光芒", "闪闪发光", "心生敬意"}},
|
||||
{"emoji": "😪", "label": "困倦", "phrases": []string{"好困呀", "眼皮打架了", "需要小憩", "睡个好觉"}},
|
||||
{"emoji": "😋", "label": "满足", "phrases": []string{"心满意足", "幸福感满满", "知足常乐", "十分满足"}},
|
||||
}
|
||||
|
||||
var PAY_CHANNELS = []map[string]interface{}{
|
||||
{"value": "wechat", "label": "微信", "types": []int{1, 2}},
|
||||
{"value": "alipay", "label": "支付宝", "types": []int{1, 2}},
|
||||
{"value": "unionpay", "label": "云闪付", "types": []int{1, 2}},
|
||||
{"value": "bank", "label": "银行卡", "types": []int{1, 2}},
|
||||
{"value": "cash", "label": "现金", "types": []int{1, 2}},
|
||||
{"value": "online_banking", "label": "网银", "types": []int{1, 2}},
|
||||
{"value": "pos", "label": "POS机", "types": []int{1, 2}},
|
||||
{"value": "other", "label": "其他", "types": []int{1, 2}},
|
||||
{"value": "salary", "label": "工资", "types": []int{2}},
|
||||
{"value": "red_packet", "label": "红包", "types": []int{2}},
|
||||
{"value": "transfer_in", "label": "转账", "types": []int{2}},
|
||||
{"value": "corporate", "label": "对公转账", "types": []int{2}},
|
||||
{"value": "credit", "label": "信用卡", "types": []int{1}},
|
||||
{"value": "huawei", "label": "花呗", "types": []int{1}},
|
||||
{"value": "white", "label": "白条", "types": []int{1}},
|
||||
{"value": "jd_pay", "label": "京东支付", "types": []int{1}},
|
||||
{"value": "meituan_pay", "label": "美团支付", "types": []int{1}},
|
||||
{"value": "gift_card", "label": "储值卡", "types": []int{1}},
|
||||
}
|
||||
|
||||
// AVATAR_BASE_URL 头像基础URL
|
||||
const AVATAR_BASE_URL = "/static/avatar/"
|
||||
|
||||
// DEFAULT_AVATARS 默认头像列表
|
||||
var DEFAULT_AVATARS = []map[string]interface{}{
|
||||
{"url": AVATAR_BASE_URL + "ginger_cat.jpg", "name": "ginger_cat", "label": "橘猫"},
|
||||
{"url": AVATAR_BASE_URL + "ragdoll.jpg", "name": "ragdoll", "label": "布偶猫"},
|
||||
{"url": AVATAR_BASE_URL + "hamster.jpg", "name": "hamster", "label": "小仓鼠"},
|
||||
{"url": AVATAR_BASE_URL + "dragon.jpg", "name": "dragon", "label": "龙"},
|
||||
{"url": AVATAR_BASE_URL + "pony.jpg", "name": "pony", "label": "小马"},
|
||||
{"url": AVATAR_BASE_URL + "lamb.jpg", "name": "lamb", "label": "小羊"},
|
||||
{"url": AVATAR_BASE_URL + "corgi.jpg", "name": "corgi", "label": "柯基"},
|
||||
{"url": AVATAR_BASE_URL + "piglet.jpg", "name": "piglet", "label": "小猪"},
|
||||
}
|
||||
|
||||
func (h *constantsHandler) GetBillCategories(c *gin.Context) {
|
||||
utils.Ok(c, DEFAULT_CATE_LIST)
|
||||
}
|
||||
|
||||
func (h *constantsHandler) GetTodoCategories(c *gin.Context) {
|
||||
data := map[string]interface{}{
|
||||
"categories": TODO_BASE_CATE,
|
||||
"priorities": TODO_PRIORITY_LIST,
|
||||
}
|
||||
utils.Ok(c, data)
|
||||
}
|
||||
|
||||
func (h *constantsHandler) GetMoodList(c *gin.Context) {
|
||||
utils.Ok(c, MOOD_LIST)
|
||||
}
|
||||
|
||||
func (h *constantsHandler) GetPayChannels(c *gin.Context) {
|
||||
utils.Ok(c, PAY_CHANNELS)
|
||||
}
|
||||
|
||||
func (h *constantsHandler) GetAllConstants(c *gin.Context) {
|
||||
data := map[string]interface{}{
|
||||
"billCategories": DEFAULT_CATE_LIST,
|
||||
"todoCategories": TODO_BASE_CATE,
|
||||
"todoPriorities": TODO_PRIORITY_LIST,
|
||||
"moodList": MOOD_LIST,
|
||||
"payChannels": PAY_CHANNELS,
|
||||
"avatars": DEFAULT_AVATARS,
|
||||
}
|
||||
utils.Ok(c, data)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// 首页聚合数据接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
type DashboardHandler interface {
|
||||
DailyDashboard(c *gin.Context) // 首页聚合数据
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体 --------------------------
|
||||
type dashboardHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDashboardHandler() DashboardHandler {
|
||||
return &dashboardHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 3. 请求结构体 --------------------------
|
||||
type DailyDashboardReq struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD,不传默认今天
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// DailyDashboard 首页聚合数据
|
||||
func (h *dashboardHandler) DailyDashboard(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req DailyDashboardReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 日期处理
|
||||
var targetDate string
|
||||
if req.Date == "" {
|
||||
targetDate = time.Now().Format("2006-01-02")
|
||||
} else {
|
||||
targetDate = req.Date
|
||||
}
|
||||
|
||||
// ========== 1. 待办数据 ==========
|
||||
var todoTotal, todoDone, todoUndone int64
|
||||
h.db.Model(&models.Todo{}).
|
||||
Where("user_id = ? AND date = ?", userID, targetDate).
|
||||
Count(&todoTotal)
|
||||
h.db.Model(&models.Todo{}).
|
||||
Where("user_id = ? AND date = ? AND done = 1", userID, targetDate).
|
||||
Count(&todoDone)
|
||||
todoUndone = todoTotal - todoDone
|
||||
|
||||
// 待办列表(最近3条)
|
||||
var todoList []models.Todo
|
||||
h.db.Model(&models.Todo{}).
|
||||
Where("user_id = ? AND date = ?", userID, targetDate).
|
||||
Order("priority ASC, id ASC").
|
||||
Limit(3).
|
||||
Find(&todoList)
|
||||
|
||||
todoData := gin.H{
|
||||
"total": todoTotal,
|
||||
"done": todoDone,
|
||||
"undone": todoUndone,
|
||||
"list": todoList,
|
||||
}
|
||||
|
||||
// ========== 2. 账单数据 ==========
|
||||
var billExpend, billIncome float64
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date = ? AND type = 1", userID, targetDate).
|
||||
Select("COALESCE(SUM(money), 0)").Scan(&billExpend)
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date = ? AND type = 2", userID, targetDate).
|
||||
Select("COALESCE(SUM(money), 0)").Scan(&billIncome)
|
||||
|
||||
// 账单列表(最近3条)
|
||||
var billList []models.Bill
|
||||
h.db.Model(&models.Bill{}).
|
||||
Where("user_id = ? AND date = ?", userID, targetDate).
|
||||
Order("id DESC").
|
||||
Limit(3).
|
||||
Find(&billList)
|
||||
|
||||
billData := gin.H{
|
||||
"expend": billExpend,
|
||||
"income": billIncome,
|
||||
"balance": billIncome - billExpend,
|
||||
"list": billList,
|
||||
}
|
||||
|
||||
// ========== 3. 心情数据 ==========
|
||||
type MoodResult struct {
|
||||
ID uint `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Emoji string `json:"emoji"`
|
||||
Content string `json:"content"`
|
||||
AIStatus int `json:"ai_status"`
|
||||
AIText string `json:"ai_text"`
|
||||
AIImageURL string `json:"ai_image_url"`
|
||||
AIGenerateCount int `json:"ai_generate_count"`
|
||||
}
|
||||
|
||||
var moodResult MoodResult
|
||||
h.db.Model(&models.Mood{}).
|
||||
Select(`
|
||||
sm_mood.id,
|
||||
sm_mood.date,
|
||||
sm_mood.emoji,
|
||||
sm_mood.content,
|
||||
COALESCE(sm_mood_ai_generations.status, 0) as ai_status,
|
||||
COALESCE(sm_mood_ai_generations.ai_text, '') as ai_text,
|
||||
COALESCE(sm_mood_ai_generations.ai_image_url, '') as ai_image_url,
|
||||
COALESCE(sm_mood_ai_generations.generate_count, 0) as ai_generate_count
|
||||
`).
|
||||
Joins("LEFT JOIN sm_mood_ai_generations ON sm_mood.id = sm_mood_ai_generations.mood_id").
|
||||
Where("sm_mood.user_id = ? AND sm_mood.date = ?", userID, targetDate).
|
||||
Scan(&moodResult)
|
||||
|
||||
moodData := gin.H{
|
||||
"recorded": moodResult.ID > 0,
|
||||
"emoji": moodResult.Emoji,
|
||||
"content": moodResult.Content,
|
||||
"ai_generated": moodResult.AIStatus == 2,
|
||||
"ai_text": moodResult.AIText,
|
||||
}
|
||||
if moodResult.ID == 0 {
|
||||
moodData = gin.H{
|
||||
"recorded": false,
|
||||
"emoji": "",
|
||||
"content": "",
|
||||
"ai_generated": false,
|
||||
"ai_text": "",
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 4. 复盘数据 ==========
|
||||
var review models.Review
|
||||
err := h.db.Where("user_id = ? AND date = ?", userID, targetDate).First(&review).Error
|
||||
reviewData := gin.H{
|
||||
"recorded": err == nil,
|
||||
"method": "kpt",
|
||||
}
|
||||
if err == nil {
|
||||
reviewData["id"] = review.ID
|
||||
reviewData["keep"] = review.Keep
|
||||
reviewData["problem"] = review.Problem
|
||||
reviewData["try"] = review.Try
|
||||
// 生成简短摘要
|
||||
summary := ""
|
||||
if review.Keep != "" {
|
||||
s := review.Keep
|
||||
if len(s) > 30 {
|
||||
s = s[:30] + "..."
|
||||
}
|
||||
summary = s
|
||||
}
|
||||
reviewData["summary"] = summary
|
||||
}
|
||||
|
||||
// ========== 5. 成长数据 ==========
|
||||
growthHandler := NewGrowthHandler()
|
||||
growthInfo := growthHandler.GetGrowthInfoInternal(userID)
|
||||
|
||||
growthData := gin.H{
|
||||
"todayExp": 0, // 今日经验,需要从 ExpChangeLog 查询
|
||||
"maxDailyExp": 120,
|
||||
"streakDays": growthInfo["streak_days"],
|
||||
}
|
||||
|
||||
// 查询今日经验
|
||||
var todayExp int
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if targetDate == today {
|
||||
h.db.Model(&models.ExpChangeLog{}).
|
||||
Where("user_id = ? AND DATE(created_at) = ?", userID, today).
|
||||
Select("COALESCE(SUM(exp), 0)").
|
||||
Scan(&todayExp)
|
||||
growthData["todayExp"] = todayExp
|
||||
}
|
||||
|
||||
// ========== 组装结果 ==========
|
||||
result := gin.H{
|
||||
"date": targetDate,
|
||||
"todo": todoData,
|
||||
"bill": billData,
|
||||
"mood": moodData,
|
||||
"review": reviewData,
|
||||
"growth": growthData,
|
||||
}
|
||||
|
||||
utils.Ok(c, result)
|
||||
}
|
||||
+1311
File diff suppressed because it is too large
Load Diff
+411
@@ -0,0 +1,411 @@
|
||||
// 心情接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
// MoodHandler 心情模块接口定义
|
||||
type MoodHandler interface {
|
||||
GetMoodByDate(c *gin.Context)
|
||||
SaveMood(c *gin.Context)
|
||||
GetMoodList(c *gin.Context)
|
||||
DeleteMood(c *gin.Context)
|
||||
MoodStat(c *gin.Context) // 心情统计
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体 --------------------------
|
||||
// moodHandler 接口实现结构体
|
||||
type moodHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewMoodHandler 创建心情处理器
|
||||
func NewMoodHandler() MoodHandler {
|
||||
return &moodHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 3. 请求结构体 --------------------------
|
||||
// GetMoodByDateReq 获取某天心情请求
|
||||
type GetMoodByDateReq struct {
|
||||
Date string `json:"date" binding:"required"`
|
||||
}
|
||||
|
||||
// SaveMoodReq 保存心情请求
|
||||
type SaveMoodReq struct {
|
||||
ID uint `json:"id"` // 编辑时传入
|
||||
Date string `json:"date" binding:"required"`
|
||||
Emoji string `json:"emoji" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// GetMoodListReq 获取心情列表请求
|
||||
type GetMoodListReq struct {
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
}
|
||||
|
||||
// DeleteMoodReq 删除心情请求
|
||||
type DeleteMoodReq struct {
|
||||
ID uint `json:"id" binding:"required"`
|
||||
}
|
||||
|
||||
// MoodStatReq 心情统计请求
|
||||
type MoodStatReq struct {
|
||||
Month string `json:"month" binding:"required"` // YYYY-MM
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// MoodCalendarItem 日历心情项(优化后的返回结构)
|
||||
type MoodCalendarItem struct {
|
||||
ID uint `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Emoji string `json:"emoji"`
|
||||
Content string `json:"content"`
|
||||
AIStatus int `json:"ai_status"` // 0无 1生成中 2成功 3失败
|
||||
AIText string `json:"ai_text"`
|
||||
AIImageURL string `json:"ai_image_url"`
|
||||
AIGenerateCount int `json:"ai_generate_count"`
|
||||
}
|
||||
|
||||
// GetMoodByDate 获取某天的心情(包含AI生成内容)
|
||||
func (h *moodHandler) GetMoodByDate(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req GetMoodByDateReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 GORM Joins 联表查询
|
||||
var result MoodCalendarItem
|
||||
h.db.Model(&models.Mood{}).
|
||||
Select(`
|
||||
sm_mood.ID,
|
||||
sm_mood.date,
|
||||
sm_mood.emoji,
|
||||
sm_mood.content,
|
||||
COALESCE(sm_mood_ai_generations.status, 0) as ai_status,
|
||||
COALESCE(sm_mood_ai_generations.ai_text, '') as ai_text,
|
||||
COALESCE(sm_mood_ai_generations.ai_image_url, '') as ai_image_url,
|
||||
COALESCE(sm_mood_ai_generations.generate_count, 0) as ai_generate_count
|
||||
`).
|
||||
Joins("LEFT JOIN sm_mood_ai_generations ON sm_mood.id = sm_mood_ai_generations.mood_id").
|
||||
Where("sm_mood.user_id = ? AND sm_mood.date = ?", userID, req.Date).
|
||||
Scan(&result)
|
||||
|
||||
// 检查是否找到记录
|
||||
if result.ID == 0 {
|
||||
utils.Ok(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, result)
|
||||
}
|
||||
|
||||
// SaveMood 保存心情
|
||||
func (h *moodHandler) SaveMood(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req SaveMoodReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 敏感词过滤
|
||||
filteredContent := utils.FilterSensitive(req.Content)
|
||||
|
||||
var mood models.Mood
|
||||
isNewRecord := false
|
||||
|
||||
// 如果传入了 id,先尝试按 id 查找
|
||||
if req.ID > 0 {
|
||||
err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&mood).Error
|
||||
if err == nil {
|
||||
// 编辑模式
|
||||
mood.Emoji = req.Emoji
|
||||
mood.Content = filteredContent
|
||||
if err := h.db.Save(&mood).Error; err != nil {
|
||||
utils.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// id 找不到,按日期查找
|
||||
err = h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&mood).Error
|
||||
if err == nil {
|
||||
mood.Emoji = req.Emoji
|
||||
mood.Content = filteredContent
|
||||
if err := h.db.Save(&mood).Error; err != nil {
|
||||
utils.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 新增模式
|
||||
mood = models.Mood{
|
||||
UserID: userID,
|
||||
Date: req.Date,
|
||||
Emoji: req.Emoji,
|
||||
Content: filteredContent,
|
||||
}
|
||||
if err := h.db.Create(&mood).Error; err != nil {
|
||||
utils.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
isNewRecord = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有传入 id,按日期查找
|
||||
err := h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&mood).Error
|
||||
if err == nil {
|
||||
// 更新已有记录
|
||||
mood.Emoji = req.Emoji
|
||||
mood.Content = filteredContent
|
||||
if err := h.db.Save(&mood).Error; err != nil {
|
||||
utils.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
isNewRecord = true
|
||||
} else {
|
||||
// 新增记录
|
||||
mood = models.Mood{
|
||||
UserID: userID,
|
||||
Date: req.Date,
|
||||
Emoji: req.Emoji,
|
||||
Content: filteredContent,
|
||||
}
|
||||
if err := h.db.Create(&mood).Error; err != nil {
|
||||
utils.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
isNewRecord = true
|
||||
}
|
||||
}
|
||||
|
||||
var growthResult any
|
||||
debugMode := os.Getenv("DEBUG_MODE")
|
||||
if isNewRecord {
|
||||
// ==================== 调试模式 ====================
|
||||
if debugMode == "true" {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
levelUp := true // rand.Intn(10) < 2 // 20%概率升级
|
||||
hasAchievement := true // rand.Intn(10) < 3 // 30%概率获得成就
|
||||
growthResult = MockGrowthRewards(levelUp, hasAchievement)
|
||||
} else {
|
||||
// 生产模式:真实调用
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if req.Date == today {
|
||||
ctx := context.Background()
|
||||
key := fmt.Sprintf("mood_count:%d:%s", userID, today)
|
||||
exists, _ := global.Redis.Exists(ctx, key).Result()
|
||||
if exists == 0 {
|
||||
growthHandler := NewGrowthHandler()
|
||||
growthResult = growthHandler.AddExpInternal(userID, "mood", fmt.Sprintf("%d", mood.ID), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ==================================================
|
||||
}
|
||||
|
||||
// 异步触发AI生成(仅新记录触发,编辑不触发)
|
||||
if isNewRecord {
|
||||
GenerateMoodAIContent(mood.ID, userID, mood.Emoji, mood.Content, mood.Date)
|
||||
}
|
||||
|
||||
// 查询 AI 生成记录并填充
|
||||
var aiGen models.MoodAIGeneration
|
||||
h.db.Where("mood_id = ?", mood.ID).First(&aiGen)
|
||||
mood.AIGen = aiGen
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"success": true,
|
||||
"growth": growthResult,
|
||||
"mood": mood,
|
||||
"debugMode": debugMode,
|
||||
"isDebug": debugMode == "true",
|
||||
"isNewRecord": isNewRecord,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMoodList 获取心情列表(包含AI生成内容)
|
||||
func (h *moodHandler) GetMoodList(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req GetMoodListReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建基础查询
|
||||
query := h.db.Model(&models.Mood{}).
|
||||
Select(`
|
||||
sm_mood.ID,
|
||||
sm_mood.date,
|
||||
sm_mood.emoji,
|
||||
sm_mood.content,
|
||||
COALESCE(sm_mood_ai_generations.status, 0) as ai_status,
|
||||
COALESCE(sm_mood_ai_generations.ai_text, '') as ai_text,
|
||||
COALESCE(sm_mood_ai_generations.ai_image_url, '') as ai_image_url,
|
||||
COALESCE(sm_mood_ai_generations.generate_count, 0) as ai_generate_count
|
||||
`).
|
||||
Joins("LEFT JOIN sm_mood_ai_generations ON sm_mood.id = sm_mood_ai_generations.mood_id").
|
||||
Where("sm_mood.user_id = ?", userID)
|
||||
|
||||
// 日期筛选
|
||||
if req.StartDate != "" && req.EndDate != "" {
|
||||
query = query.Where("sm_mood.date BETWEEN ? AND ?", req.StartDate, req.EndDate)
|
||||
} else if req.StartDate != "" {
|
||||
query = query.Where("sm_mood.date >= ?", req.StartDate)
|
||||
} else if req.EndDate != "" {
|
||||
query = query.Where("sm_mood.date <= ?", req.EndDate)
|
||||
}
|
||||
|
||||
var results []MoodCalendarItem
|
||||
if err := query.Order("sm_mood.date DESC").Scan(&results).Error; err != nil {
|
||||
utils.Fail(c, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, results)
|
||||
}
|
||||
|
||||
// DeleteMood 删除心情
|
||||
func (h *moodHandler) DeleteMood(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req DeleteMoodReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询心情信息(用于扣除积分)
|
||||
var mood models.Mood
|
||||
err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&mood).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "心情记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除心情
|
||||
err = h.db.Where("id = ? AND user_id = ?", req.ID, userID).Delete(&models.Mood{}).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 扣除今天添加时获得的积分
|
||||
growthHandler := NewGrowthHandler()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
deducted := growthHandler.DeductExpInternal(userID, "mood", fmt.Sprintf("%d", req.ID), today)
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"deducted_exp": deducted,
|
||||
})
|
||||
}
|
||||
|
||||
// MoodStat 心情统计
|
||||
func (h *moodHandler) MoodStat(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req MoodStatReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 计算月份范围
|
||||
startDate := req.Month + "-01"
|
||||
year, month := 0, 0
|
||||
fmt.Sscanf(req.Month, "%d-%d", &year, &month)
|
||||
_, lastDay := utils.GetMonthLastDay(year, month)
|
||||
endDate := fmt.Sprintf("%s-%02d", req.Month, lastDay)
|
||||
|
||||
// 查询该月的心情记录
|
||||
var moods []models.Mood
|
||||
h.db.Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Order("date ASC").
|
||||
Find(&moods)
|
||||
|
||||
// 心情分布
|
||||
moodDistribution := make(map[string]int)
|
||||
for _, m := range moods {
|
||||
moodDistribution[m.Emoji]++
|
||||
}
|
||||
|
||||
// 计算总天数和已记录天数
|
||||
totalDays := lastDay
|
||||
recordedDays := len(moods)
|
||||
|
||||
// 周趋势(按周分组)
|
||||
weeklyTrend := []gin.H{}
|
||||
weekMap := make(map[string][]models.Mood)
|
||||
|
||||
for _, m := range moods {
|
||||
// 解析日期,计算是第几周
|
||||
t, _ := time.Parse("2006-01-02", m.Date)
|
||||
_, week := t.ISOWeek()
|
||||
weekKey := fmt.Sprintf("W%d", week)
|
||||
weekMap[weekKey] = append(weekMap[weekKey], m)
|
||||
}
|
||||
|
||||
// 计算每周的平均分数
|
||||
// 心情emoji映射到分数(简单映射)
|
||||
emojiScore := map[string]float64{
|
||||
"😊": 5.0, "😄": 5.0, "😎": 4.5,
|
||||
"😌": 4.0, "🙂": 3.5,
|
||||
"😐": 3.0,
|
||||
"😔": 2.5, "😢": 2.0,
|
||||
"😤": 2.0, "😰": 1.5,
|
||||
"😴": 3.0, "🤔": 3.0,
|
||||
}
|
||||
|
||||
for weekKey, weekMoods := range weekMap {
|
||||
var totalScore float64
|
||||
for _, m := range weekMoods {
|
||||
if score, ok := emojiScore[m.Emoji]; ok {
|
||||
totalScore += score
|
||||
} else {
|
||||
totalScore += 3.0 // 默认中等分数
|
||||
}
|
||||
}
|
||||
avgScore := 0.0
|
||||
if len(weekMoods) > 0 {
|
||||
avgScore = totalScore / float64(len(weekMoods))
|
||||
}
|
||||
weeklyTrend = append(weeklyTrend, gin.H{
|
||||
"week": weekKey,
|
||||
"avg_score": avgScore,
|
||||
})
|
||||
}
|
||||
|
||||
// 高频词汇(这里简化处理,返回固定示例)
|
||||
topPhrases := []string{"开心", "平静", "工作顺利", "充实", "放松"}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"month": req.Month,
|
||||
"total_days": totalDays,
|
||||
"recorded_days": recordedDays,
|
||||
"mood_distribution": moodDistribution,
|
||||
"weekly_trend": weeklyTrend,
|
||||
"top_phrases": topPhrases,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// 复盘接口(仅 KPT 法)
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
type ReviewHandler interface {
|
||||
SaveReview(c *gin.Context) // 保存复盘
|
||||
GetReview(c *gin.Context) // 获取单日复盘
|
||||
ReviewList(c *gin.Context) // 获取复盘列表
|
||||
DeleteReview(c *gin.Context) // 删除复盘
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体 --------------------------
|
||||
type reviewHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewReviewHandler() ReviewHandler {
|
||||
return &reviewHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 3. 请求结构体 --------------------------
|
||||
|
||||
// SaveReviewReq 保存复盘请求
|
||||
type SaveReviewReq struct {
|
||||
Date string `json:"date" binding:"required"` // YYYY-MM-DD
|
||||
Kpt SaveReviewKPTDto `json:"kpt"`
|
||||
}
|
||||
type SaveReviewKPTDto struct {
|
||||
Keep string `json:"keep"`
|
||||
Problem string `json:"problem"`
|
||||
Try string `json:"try"`
|
||||
}
|
||||
|
||||
// GetReviewReq 获取单日复盘请求
|
||||
type GetReviewReq struct {
|
||||
Date string `json:"date" binding:"required"`
|
||||
}
|
||||
|
||||
// ReviewListReq 获取复盘列表请求
|
||||
type ReviewListReq struct {
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Keyword string `json:"keyword"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// DeleteReviewReq 删除复盘请求
|
||||
type DeleteReviewReq struct {
|
||||
ID uint `json:"id" binding:"required"`
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// SaveReview 保存复盘
|
||||
func (h *reviewHandler) SaveReview(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req SaveReviewReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
var review models.Review
|
||||
isNewRecord := false
|
||||
|
||||
err := h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&review).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
review = models.Review{
|
||||
UserID: userID,
|
||||
Date: req.Date,
|
||||
Keep: utils.FilterSensitive(req.Kpt.Keep),
|
||||
Problem: utils.FilterSensitive(req.Kpt.Problem),
|
||||
Try: utils.FilterSensitive(req.Kpt.Try),
|
||||
}
|
||||
isNewRecord = true
|
||||
} else if err != nil {
|
||||
utils.Fail(c, "查询失败")
|
||||
return
|
||||
} else {
|
||||
review.Keep = utils.FilterSensitive(req.Kpt.Keep)
|
||||
review.Problem = utils.FilterSensitive(req.Kpt.Problem)
|
||||
review.Try = utils.FilterSensitive(req.Kpt.Try)
|
||||
}
|
||||
|
||||
if isNewRecord {
|
||||
if err := h.db.Create(&review).Error; err != nil {
|
||||
utils.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.db.Save(&review).Error; err != nil {
|
||||
utils.Fail(c, "保存失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var growthResult any
|
||||
debugMode := os.Getenv("DEBUG_MODE")
|
||||
if isNewRecord {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if req.Date == today {
|
||||
if debugMode == "true" {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
levelUp := rand.Intn(10) < 2
|
||||
hasAchievement := rand.Intn(10) < 3
|
||||
growthResult = MockGrowthRewards(levelUp, hasAchievement)
|
||||
} else {
|
||||
growthHandler := NewGrowthHandler()
|
||||
growthResult = growthHandler.AddExpInternal(userID, "review", fmt.Sprintf("%d", review.ID), nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"success": true,
|
||||
"growth": growthResult,
|
||||
"review": review,
|
||||
"isNewRecord": isNewRecord,
|
||||
})
|
||||
}
|
||||
|
||||
// GetReview 获取单日复盘
|
||||
func (h *reviewHandler) GetReview(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req GetReviewReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
var review models.Review
|
||||
err := h.db.Where("user_id = ? AND date = ?", userID, req.Date).First(&review).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
utils.Ok(c, nil)
|
||||
return
|
||||
} else if err != nil {
|
||||
utils.Fail(c, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, review)
|
||||
}
|
||||
|
||||
// ReviewList 获取复盘列表
|
||||
func (h *reviewHandler) ReviewList(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req ReviewListReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
|
||||
db := h.db.Model(&models.Review{}).Where("user_id = ?", userID)
|
||||
|
||||
if req.StartDate != "" && req.EndDate != "" {
|
||||
db = db.Where("date BETWEEN ? AND ?", req.StartDate, req.EndDate)
|
||||
} else if req.StartDate != "" {
|
||||
db = db.Where("date >= ?", req.StartDate)
|
||||
} else if req.EndDate != "" {
|
||||
db = db.Where("date <= ?", req.EndDate)
|
||||
}
|
||||
|
||||
if req.Keyword != "" {
|
||||
keyword := "%" + req.Keyword + "%"
|
||||
db = db.Where("(keep LIKE ? OR problem LIKE ? OR try LIKE ?)", keyword, keyword, keyword)
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var list []models.Review
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
err := db.Order("date DESC").Offset(offset).Limit(req.PageSize).Find(&list).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
type ReviewListItem struct {
|
||||
ID uint `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
result := make([]ReviewListItem, len(list))
|
||||
for i, r := range list {
|
||||
summary := r.Keep
|
||||
if len(summary) > 50 {
|
||||
summary = summary[:50] + "..."
|
||||
}
|
||||
result[i] = ReviewListItem{
|
||||
ID: r.ID,
|
||||
Date: r.Date,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
"page": req.Page,
|
||||
"pageSize": req.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteReview 删除复盘
|
||||
func (h *reviewHandler) DeleteReview(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req DeleteReviewReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
var review models.Review
|
||||
err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&review).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
utils.Fail(c, "复盘不存在")
|
||||
return
|
||||
} else if err != nil {
|
||||
utils.Fail(c, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&review).Error; err != nil {
|
||||
utils.Fail(c, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
growthHandler := NewGrowthHandler()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
deducted := growthHandler.DeductExpInternal(userID, "review", fmt.Sprintf("%d", req.ID), today)
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"success": true,
|
||||
"deducted_exp": deducted,
|
||||
})
|
||||
}
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
// 待办接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
// TodoHandler 待办模块接口定义
|
||||
type TodoHandler interface {
|
||||
AddTodo(c *gin.Context) // 添加待办
|
||||
TodoList(c *gin.Context) // 获取待办列表
|
||||
DeleteTodo(c *gin.Context) // 删除待办
|
||||
DoneTodo(c *gin.Context) // 完成/取消完成待办
|
||||
EditTodo(c *gin.Context) // 编辑待办
|
||||
TodoStat(c *gin.Context) // 待办统计
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体 --------------------------
|
||||
// todoHandler 接口实现结构体
|
||||
type todoHandler struct {
|
||||
db *gorm.DB // 数据库依赖
|
||||
}
|
||||
|
||||
// NewTodoHandler 创建待办处理器
|
||||
func NewTodoHandler() TodoHandler {
|
||||
return &todoHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 3. 请求结构体 --------------------------
|
||||
// TodoListReq 列表请求参数
|
||||
type TodoListReq struct {
|
||||
Search string `json:"search"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
}
|
||||
|
||||
// DeleteTodoReq 删除待办请求
|
||||
type DeleteTodoReq struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
// DoneTodoReq 完成待办请求
|
||||
type DoneTodoReq struct {
|
||||
ID uint `json:"id"`
|
||||
Status uint `json:"status"` // 0-取消完成 1-完成
|
||||
}
|
||||
|
||||
// EditTodoReq 编辑待办请求(与model对齐)
|
||||
type EditTodoReq struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Priority int `json:"priority"`
|
||||
Remark string `json:"remark"`
|
||||
Date string `json:"date"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
}
|
||||
|
||||
// TodoStatReq 待办统计请求
|
||||
type TodoStatReq struct {
|
||||
StartDate string `json:"start_date" binding:"required"` // 开始日期
|
||||
EndDate string `json:"end_date" binding:"required"` // 结束日期
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// GetTodoListForCalendar 获取日历月份的待办列表(内部方法)
|
||||
func (h *todoHandler) GetTodoListForCalendar(c *gin.Context, year, month int) []interface{} {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// 计算月份范围
|
||||
startDate := fmt.Sprintf("%d-%02d-01", year, month)
|
||||
// 计算月末
|
||||
_, lastDay := utils.GetMonthLastDay(year, month)
|
||||
endDate := fmt.Sprintf("%d-%02d-%02d", year, month, lastDay)
|
||||
|
||||
var list []models.Todo
|
||||
h.db.Model(&models.Todo{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Order("date ASC, id ASC").
|
||||
Find(&list)
|
||||
|
||||
// 转换结果
|
||||
result := make([]interface{}, len(list))
|
||||
for i, v := range list {
|
||||
if v.Done == 0 && v.Date < time.Now().Format("2006-01-02") {
|
||||
v.IsOverdue = 1
|
||||
} else {
|
||||
v.IsOverdue = 0
|
||||
}
|
||||
result[i] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AddTodo 添加待办
|
||||
func (h *todoHandler) AddTodo(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var todo models.Todo
|
||||
|
||||
if err := c.ShouldBindJSON(&todo); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 敏感词过滤
|
||||
todo.Title = utils.FilterSensitive(todo.Title)
|
||||
todo.Remark = utils.FilterSensitive(todo.Remark)
|
||||
|
||||
// 检查每日限制(最多100个待办/天)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
var todayCount int64
|
||||
if err := h.db.Model(&models.Todo{}).Where("user_id = ? AND date = ? AND deleted_at IS NULL", userID, today).Count(&todayCount).Error; err != nil {
|
||||
utils.Fail(c, "系统错误")
|
||||
return
|
||||
}
|
||||
if todayCount >= 100 {
|
||||
utils.Fail(c, "今日待办数量已达上限(100个)")
|
||||
return
|
||||
}
|
||||
|
||||
todo.UserID = userID
|
||||
if err := h.db.Create(&todo).Error; err != nil {
|
||||
utils.Fail(c, "添加失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
|
||||
// TodoList 获取待办列表
|
||||
func (h *todoHandler) TodoList(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var req TodoListReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建查询
|
||||
db := h.db.Model(&models.Todo{}).Where("user_id = ?", userID)
|
||||
|
||||
// 搜索
|
||||
if req.Search != "" {
|
||||
db = db.Where("title LIKE ? OR remark LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
switch req.Status {
|
||||
case "undone":
|
||||
db = db.Where("done = 0")
|
||||
case "done":
|
||||
db = db.Where("done = 1")
|
||||
}
|
||||
|
||||
// 日期
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
|
||||
startDate := req.StartDate
|
||||
endDate := req.EndDate
|
||||
if startDate != "" && endDate != "" {
|
||||
db = db.Where("date BETWEEN ? AND ?", startDate, endDate)
|
||||
} else if startDate != "" {
|
||||
db = db.Where("date >= ?", startDate)
|
||||
} else if endDate != "" {
|
||||
db = db.Where("date <= ?", endDate)
|
||||
} else if req.Date != "" {
|
||||
db = db.Where("date = ?", req.Date)
|
||||
} else {
|
||||
db = db.Where("date = ?", today)
|
||||
}
|
||||
|
||||
// 排序
|
||||
db = db.Order("date ASC, id ASC")
|
||||
|
||||
// 查询
|
||||
var list []models.Todo
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
utils.Fail(c, "获取失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 逾期状态
|
||||
for i := range list {
|
||||
if list[i].Done == 0 && list[i].Date < today {
|
||||
list[i].IsOverdue = 1
|
||||
} else {
|
||||
list[i].IsOverdue = 0
|
||||
}
|
||||
}
|
||||
|
||||
utils.Ok(c, list)
|
||||
}
|
||||
|
||||
// DeleteTodo 删除待办
|
||||
func (h *todoHandler) DeleteTodo(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req DeleteTodoReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询待办信息(用于扣除积分)
|
||||
var todo models.Todo
|
||||
err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&todo).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "待办不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除待办
|
||||
err = h.db.Where("id = ? AND user_id = ?", req.ID, userID).Delete(&models.Todo{}).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 扣除今天添加时获得的积分
|
||||
growthHandler := NewGrowthHandler()
|
||||
today := time.Now().Format("2006-01-02")
|
||||
deducted := growthHandler.DeductExpInternal(userID, "task", fmt.Sprintf("%d", req.ID), today)
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"deducted_exp": deducted,
|
||||
})
|
||||
}
|
||||
|
||||
// DoneTodo 完成/取消完成待办
|
||||
func (h *todoHandler) DoneTodo(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req DoneTodoReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
if req.Status != 0 && req.Status != 1 {
|
||||
utils.Fail(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 先查询当前状态
|
||||
var todo models.Todo
|
||||
err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&todo).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "待办不存在")
|
||||
return
|
||||
}
|
||||
|
||||
oldDone := todo.Done
|
||||
|
||||
// 更新状态
|
||||
err = h.db.Model(&models.Todo{}).
|
||||
Where("id = ? AND user_id = ?", req.ID, userID).
|
||||
Update("done", req.Status).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
var result any
|
||||
debugMode := os.Getenv("DEBUG_MODE")
|
||||
// 只有从未完成变成完成(0→1)的时候才加积分,并且这个待办之前没有加过积分
|
||||
if oldDone == 0 && req.Status == 1 {
|
||||
// 查询是否已经给这个待办加过积分
|
||||
var count int64
|
||||
h.db.Model(&models.ExpChangeLog{}).
|
||||
Where("user_id = ? AND source = ? AND source_id = ?", userID, "task", fmt.Sprintf("%d", req.ID)).
|
||||
Count(&count)
|
||||
|
||||
// 只有之前没加过积分才加
|
||||
if count == 0 {
|
||||
// ==================== 调试模式 ====================
|
||||
if debugMode == "true" {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
levelUp := rand.Intn(10) < 3 // 30%概率升级
|
||||
hasAchievement := rand.Intn(10) < 4 // 40%概率获得成就
|
||||
result = MockGrowthRewards(levelUp, hasAchievement)
|
||||
} else {
|
||||
// 生产模式:真实调用
|
||||
growthHandler := NewGrowthHandler()
|
||||
result = growthHandler.AddExpInternal(userID, "task", fmt.Sprintf("%d", req.ID), nil)
|
||||
}
|
||||
// ==================================================
|
||||
}
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"success": true,
|
||||
"growth": result,
|
||||
"debugMode": debugMode,
|
||||
"isDebug": debugMode == "true",
|
||||
})
|
||||
}
|
||||
|
||||
// EditTodo 编辑待办
|
||||
func (h *todoHandler) EditTodo(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req EditTodoReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 敏感词过滤
|
||||
filteredTitle := utils.FilterSensitive(req.Title)
|
||||
filteredRemark := utils.FilterSensitive(req.Remark)
|
||||
|
||||
data := map[string]any{
|
||||
"title": filteredTitle,
|
||||
"remark": filteredRemark,
|
||||
"category": req.Category,
|
||||
"priority": req.Priority,
|
||||
"date": req.Date,
|
||||
"start_time": req.StartTime,
|
||||
"end_time": req.EndTime,
|
||||
}
|
||||
|
||||
err := h.db.Model(&models.Todo{}).
|
||||
Where("id = ? AND user_id = ?", req.ID, userID).
|
||||
Updates(data).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "编辑失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
|
||||
// TodoStat 待办统计
|
||||
func (h *todoHandler) TodoStat(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
var req TodoStatReq
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 总体统计
|
||||
var total, done, undone int64
|
||||
h.db.Model(&models.Todo{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate).
|
||||
Count(&total)
|
||||
h.db.Model(&models.Todo{}).
|
||||
Where("user_id = ? AND date BETWEEN ? AND ? AND done = 1", userID, req.StartDate, req.EndDate).
|
||||
Count(&done)
|
||||
undone = total - done
|
||||
|
||||
// 完成率
|
||||
completionRate := 0.0
|
||||
if total > 0 {
|
||||
completionRate = float64(done) / float64(total) * 100
|
||||
}
|
||||
|
||||
// 按分类统计
|
||||
type CategoryStat struct {
|
||||
Total int64 `json:"total"`
|
||||
Done int64 `json:"done"`
|
||||
}
|
||||
byCategory := make(map[string]CategoryStat)
|
||||
|
||||
var categoryResults []struct {
|
||||
Category string
|
||||
Total int64
|
||||
Done int64
|
||||
}
|
||||
h.db.Model(&models.Todo{}).
|
||||
Select("category, COUNT(*) as total, SUM(CASE WHEN done = 1 THEN 1 ELSE 0 END) as done").
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate).
|
||||
Group("category").
|
||||
Scan(&categoryResults)
|
||||
|
||||
for _, r := range categoryResults {
|
||||
byCategory[r.Category] = CategoryStat{
|
||||
Total: r.Total,
|
||||
Done: r.Done,
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级统计
|
||||
type PriorityStat struct {
|
||||
Total int64 `json:"total"`
|
||||
Done int64 `json:"done"`
|
||||
}
|
||||
byPriority := make(map[string]PriorityStat)
|
||||
|
||||
var priorityResults []struct {
|
||||
Priority int
|
||||
Total int64
|
||||
Done int64
|
||||
}
|
||||
h.db.Model(&models.Todo{}).
|
||||
Select("priority, COUNT(*) as total, SUM(CASE WHEN done = 1 THEN 1 ELSE 0 END) as done").
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate).
|
||||
Group("priority").
|
||||
Scan(&priorityResults)
|
||||
|
||||
for _, r := range priorityResults {
|
||||
key := fmt.Sprintf("%d", r.Priority)
|
||||
byPriority[key] = PriorityStat{
|
||||
Total: r.Total,
|
||||
Done: r.Done,
|
||||
}
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"total": total,
|
||||
"done": done,
|
||||
"undone": undone,
|
||||
"completion_rate": completionRate,
|
||||
"by_category": byCategory,
|
||||
"by_priority": byPriority,
|
||||
})
|
||||
}
|
||||
+982
@@ -0,0 +1,982 @@
|
||||
// 小程序用户接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
// UserHandler 用户模块接口定义
|
||||
type UserHandler interface {
|
||||
Login(c *gin.Context) // 微信登录
|
||||
Logout(c *gin.Context) // 退出登录
|
||||
UserInfo(c *gin.Context) // 获取用户信息
|
||||
EditUser(c *gin.Context) // 修改用户信息
|
||||
SendEmailCode(c *gin.Context) // 发送邮箱验证码(支持多种场景)
|
||||
SetEmail(c *gin.Context) // 设置邮箱(绑定邮箱)
|
||||
EmailLogin(c *gin.Context) // 邮箱密码登录
|
||||
EmailRegister(c *gin.Context) // 邮箱注册
|
||||
ResetPassword(c *gin.Context) // 重置密码(未登录找回密码)
|
||||
ChangePassword(c *gin.Context) // 修改密码(已登录场景)
|
||||
CheckEmailExists(c *gin.Context) // 检查邮箱是否已注册
|
||||
BindWechat(c *gin.Context) // 绑定微信(Web端,网页授权code)
|
||||
BindWechatMini(c *gin.Context) // 绑定微信(小程序端,wx.login code)
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体(依赖注入DB) --------------------------
|
||||
// userHandler 接口实现结构体
|
||||
type userHandler struct {
|
||||
db *gorm.DB // 注入数据库,方便测试
|
||||
}
|
||||
|
||||
// NewUserHandler 创建用户处理器(对外暴露)
|
||||
func NewUserHandler() UserHandler {
|
||||
return &userHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 3. 请求结构体定义 --------------------------
|
||||
// LoginReq 登录参数
|
||||
type LoginReq struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type EditUserReq struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// Login 微信登录
|
||||
func (h *userHandler) Login(c *gin.Context) {
|
||||
var req LoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Code == "" {
|
||||
utils.Fail(c, "code不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 调用微信接口获取 openid
|
||||
wxResp, err := utils.MiniProgramCode2Session(req.Code)
|
||||
if err != nil {
|
||||
utils.Fail(c, "微信登录失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 查询或创建用户
|
||||
var user models.User
|
||||
err = h.db.Where("openid = ?", wxResp.OpenID).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 创建新用户
|
||||
user = models.User{
|
||||
Openid: wxResp.OpenID,
|
||||
UserCode: utils.GenerateUserCode(),
|
||||
}
|
||||
err = h.db.Create(&user).Error
|
||||
}
|
||||
if err != nil {
|
||||
utils.Fail(c, "用户操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID)
|
||||
if err != nil {
|
||||
utils.Fail(c, "token生成失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回数据
|
||||
utils.Ok(c, gin.H{
|
||||
"token": token,
|
||||
"expiresAt": expiresAt,
|
||||
"expiresIn": utils.GetTokenExpireSeconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 退出登录
|
||||
func (h *userHandler) Logout(c *gin.Context) {
|
||||
// 从上下文获取用户ID和token(JWTAuth中间件已注入)
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
token, tokenExists := c.Get("token")
|
||||
if !tokenExists {
|
||||
utils.Fail(c, "获取token失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 token 获取过期时间
|
||||
claims, err := utils.ParseToken(token.(string))
|
||||
if err != nil {
|
||||
utils.Fail(c, "token无效")
|
||||
return
|
||||
}
|
||||
|
||||
// 计算剩余过期时间
|
||||
ttl := time.Until(claims.ExpiresAt.Time)
|
||||
if ttl > 0 {
|
||||
// 将 token 加入 Redis 黑名单
|
||||
ctx := context.Background()
|
||||
err = global.Redis.Set(ctx, "token_blacklist:"+token.(string), "1", ttl).Err()
|
||||
if err != nil {
|
||||
global.Logger.Error("添加token到黑名单失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "退出登录失败")
|
||||
return
|
||||
}
|
||||
global.Logger.Info("用户退出登录", append(utils.LogContextFields(c), zap.Uint("userID", userId.(uint)))...)
|
||||
}
|
||||
|
||||
utils.Ok(c, "退出登录成功")
|
||||
}
|
||||
|
||||
// UserInfo 获取用户信息
|
||||
func (h *userHandler) UserInfo(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := h.db.First(&user, userId).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回脱敏用户信息
|
||||
utils.Ok(c, gin.H{
|
||||
"id": user.ID,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"signature": user.Signature,
|
||||
"email": user.Email,
|
||||
"user_code": user.UserCode,
|
||||
"is_bind_wechat": user.Openid != "", // 是否绑定微信
|
||||
"is_bind_email": user.Email != "", // 是否绑定邮箱
|
||||
"createdAt": user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// EditUser 修改用户信息
|
||||
func (h *userHandler) EditUser(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var editUserReq EditUserReq
|
||||
if err := c.ShouldBindJSON(&editUserReq); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
// 如果 昵称或者头像为空,则不更新
|
||||
if editUserReq.Nickname == "" && editUserReq.Avatar == "" && editUserReq.Signature == "" {
|
||||
utils.Ok(c, nil)
|
||||
return
|
||||
}
|
||||
// 构建更新数据(支持同时更新多个字段)
|
||||
data := make(map[string]any)
|
||||
if editUserReq.Nickname != "" {
|
||||
data["nickname"] = utils.FilterSensitive(editUserReq.Nickname) // 敏感词过滤
|
||||
}
|
||||
if editUserReq.Avatar != "" {
|
||||
data["avatar"] = editUserReq.Avatar
|
||||
}
|
||||
if editUserReq.Signature != "" {
|
||||
data["signature"] = utils.FilterSensitive(editUserReq.Signature) // 敏感词过滤
|
||||
}
|
||||
if len(data) == 0 {
|
||||
utils.Ok(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.Model(&models.User{}).Where("id = ?", userId).Updates(data).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "用户信息更新失败")
|
||||
return
|
||||
}
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
|
||||
// -------------------------- 4. 邮箱验证接口 --------------------------
|
||||
|
||||
// SendEmailCodeReq 发送邮箱验证码请求
|
||||
type SendEmailCodeReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=register login bind reset_password change_email"` // type为空时:已登录状态下绑定邮箱
|
||||
CaptchaId string `json:"captchaId"` // 图形验证码ID(注册/登录/找回密码场景必填)
|
||||
Captcha string `json:"captcha"` // 图形验证码(注册/登录/找回密码场景必填)
|
||||
}
|
||||
|
||||
// SendEmailCode 发送邮箱验证码(支持多种场景)
|
||||
// type=register: 注册,检查邮箱未被注册
|
||||
// type=login: 登录,检查邮箱已注册且有密码
|
||||
// type=bind/空: 已登录状态下绑定邮箱,不检查邮箱状态
|
||||
// type=reset_password: 找回密码,检查邮箱已注册且有密码
|
||||
// type=change_email: 修改邮箱(已登录),检查新邮箱未被其他用户使用
|
||||
func (h *userHandler) SendEmailCode(c *gin.Context) {
|
||||
var req SendEmailCodeReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "请输入有效的邮箱地址")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 获取客户端IP
|
||||
clientIP := c.ClientIP()
|
||||
if clientIP == "" {
|
||||
clientIP = c.GetHeader("X-Forwarded-For")
|
||||
}
|
||||
if clientIP == "" {
|
||||
clientIP = c.GetHeader("X-Real-IP")
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case "register":
|
||||
// 注册场景:检查邮箱未被注册,需要验证图形验证码
|
||||
// 验证图形验证码
|
||||
if req.CaptchaId == "" || req.Captcha == "" {
|
||||
utils.Fail(c, "请输入图形验证码")
|
||||
return
|
||||
}
|
||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
||||
utils.Fail(c, "图形验证码错误")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
||||
if err == nil {
|
||||
utils.Fail(c, "该邮箱已被注册")
|
||||
return
|
||||
}
|
||||
case "login":
|
||||
// 登录场景:检查邮箱已注册且有密码,需要验证图形验证码
|
||||
// 验证图形验证码
|
||||
if req.CaptchaId == "" || req.Captcha == "" {
|
||||
utils.Fail(c, "请输入图形验证码")
|
||||
return
|
||||
}
|
||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
||||
utils.Fail(c, "图形验证码错误")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, "该邮箱未注册")
|
||||
} else {
|
||||
utils.Fail(c, "查询用户失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
if user.Password == "" {
|
||||
utils.Fail(c, "该邮箱未设置密码,请先注册")
|
||||
return
|
||||
}
|
||||
case "reset_password":
|
||||
// 找回密码场景:检查邮箱已注册,需要验证图形验证码
|
||||
// 验证图形验证码
|
||||
if req.CaptchaId == "" || req.Captcha == "" {
|
||||
utils.Fail(c, "请输入图形验证码")
|
||||
return
|
||||
}
|
||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
||||
utils.Fail(c, "图形验证码错误")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, "该邮箱未注册")
|
||||
} else {
|
||||
utils.Fail(c, "查询用户失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
if user.Password == "" {
|
||||
utils.Fail(c, "该邮箱未设置密码,请先注册")
|
||||
return
|
||||
}
|
||||
case "change_email":
|
||||
// 修改邮箱场景:需要用户已登录,检查新邮箱未被其他用户使用,需要验证图形验证码
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证图形验证码
|
||||
if req.CaptchaId == "" || req.Captcha == "" {
|
||||
utils.Fail(c, "请输入图形验证码")
|
||||
return
|
||||
}
|
||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
||||
utils.Fail(c, "图形验证码错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查新邮箱是否已被其他用户使用
|
||||
var existingUser models.User
|
||||
err := h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error
|
||||
if err == nil {
|
||||
utils.Fail(c, "该邮箱已被其他用户使用")
|
||||
return
|
||||
}
|
||||
case "bind", "":
|
||||
// 绑定邮箱场景:需要用户已登录,不检查邮箱状态,不需要图形验证码
|
||||
_, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
default:
|
||||
utils.Fail(c, "无效的type参数")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. IP级别的频率限制:同一IP每分钟最多发送5次
|
||||
ipRateLimitKey := "email_ip_rate_limit:" + clientIP
|
||||
ipCount, err := global.Redis.Incr(ctx, ipRateLimitKey).Result()
|
||||
if err != nil {
|
||||
global.Logger.Error("IP频率限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "发送验证码失败")
|
||||
return
|
||||
}
|
||||
if ipCount == 1 {
|
||||
global.Redis.Expire(ctx, ipRateLimitKey, 60*time.Second)
|
||||
} else if ipCount > 5 {
|
||||
ttl, _ := global.Redis.TTL(ctx, ipRateLimitKey).Result()
|
||||
utils.Fail(c, fmt.Sprintf("当前IP发送过于频繁,请%d秒后再试", int(ttl.Seconds())))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 邮箱级别的频率限制:同一邮箱每60秒只能发送一次
|
||||
emailRateLimitKey := "email_rate_limit:" + req.Email
|
||||
emailCount, err := global.Redis.Exists(ctx, emailRateLimitKey).Result()
|
||||
if err == nil && emailCount > 0 {
|
||||
ttl, _ := global.Redis.TTL(ctx, emailRateLimitKey).Result()
|
||||
utils.Fail(c, fmt.Sprintf("该邮箱发送太频繁,请%d秒后再试", int(ttl.Seconds())))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 邮箱每日发送次数限制:同一邮箱每天最多发送10次
|
||||
now := time.Now()
|
||||
dayKey := "email_daily_limit:" + req.Email + ":" + now.Format("2006-01-02")
|
||||
dailyCount, err := global.Redis.Incr(ctx, dayKey).Result()
|
||||
if err != nil {
|
||||
global.Logger.Error("邮箱每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "发送验证码失败")
|
||||
return
|
||||
}
|
||||
if dailyCount == 1 {
|
||||
global.Redis.Expire(ctx, dayKey, 24*time.Hour)
|
||||
} else if dailyCount > 10 {
|
||||
utils.Fail(c, "该邮箱今日发送次数已达上限,请明天再试")
|
||||
return
|
||||
}
|
||||
|
||||
// 5. IP每日发送次数限制:同一IP每天最多发送30次
|
||||
ipDayKey := "email_ip_daily_limit:" + clientIP + ":" + now.Format("2006-01-02")
|
||||
ipDailyCount, err := global.Redis.Incr(ctx, ipDayKey).Result()
|
||||
if err != nil {
|
||||
global.Logger.Error("IP每日限制计数失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "发送验证码失败")
|
||||
return
|
||||
}
|
||||
if ipDailyCount == 1 {
|
||||
global.Redis.Expire(ctx, ipDayKey, 24*time.Hour)
|
||||
} else if ipDailyCount > 30 {
|
||||
utils.Fail(c, "当前IP今日发送次数已达上限,请明天再试")
|
||||
return
|
||||
}
|
||||
|
||||
code := utils.GenerateRandomString(6)
|
||||
err = global.Redis.Set(ctx, "email_code:"+req.Email, code, 5*time.Minute).Err()
|
||||
if err != nil {
|
||||
global.Logger.Error("保存邮箱验证码失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "发送验证码失败")
|
||||
return
|
||||
}
|
||||
|
||||
global.Redis.Set(ctx, emailRateLimitKey, "1", 60*time.Second)
|
||||
|
||||
err = utils.SendEmailCode(req.Email, code)
|
||||
if err != nil {
|
||||
global.Logger.Error("发送邮件失败", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.Error(err))...)
|
||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
||||
utils.Fail(c, "发送验证码失败")
|
||||
return
|
||||
}
|
||||
|
||||
global.Logger.Info("验证码发送成功", append(utils.LogContextFields(c), zap.String("email", req.Email), zap.String("code", code))...)
|
||||
utils.Ok(c, "验证码已发送,请注意查收")
|
||||
}
|
||||
|
||||
// SetEmailReq 设置邮箱请求
|
||||
type SetEmailReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Code string `json:"code" binding:"required,len=6"`
|
||||
}
|
||||
|
||||
// SetEmail 设置邮箱(需验证验证码)
|
||||
func (h *userHandler) SetEmail(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
var req SetEmailReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "请输入有效的邮箱和验证码")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result()
|
||||
if err != nil || storedCode != req.Code {
|
||||
utils.Fail(c, "验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
|
||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
||||
|
||||
var existingUser models.User
|
||||
err = h.db.Where("email = ? AND id != ?", req.Email, userId).First(&existingUser).Error
|
||||
if err == nil {
|
||||
utils.Fail(c, "该邮箱已被其他用户使用")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("email", req.Email).Error
|
||||
if err != nil {
|
||||
global.Logger.Error("更新邮箱失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "设置邮箱失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, "邮箱设置成功")
|
||||
}
|
||||
|
||||
// -------------------------- 5. 网页端邮箱登录接口 --------------------------
|
||||
|
||||
// EmailRegisterReq 邮箱注册请求
|
||||
type EmailRegisterReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Code string `json:"code" binding:"required,len=6"`
|
||||
CaptchaId string `json:"captchaId"` // 图形验证码ID
|
||||
Captcha string `json:"captcha"` // 图形验证码
|
||||
}
|
||||
|
||||
// EmailLoginReq 邮箱登录请求
|
||||
type EmailLoginReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaId string `json:"captchaId"` // 图形验证码ID
|
||||
Captcha string `json:"captcha"` // 图形验证码
|
||||
}
|
||||
|
||||
// EmailRegister 邮箱注册
|
||||
func (h *userHandler) EmailRegister(c *gin.Context) {
|
||||
var req EmailRegisterReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "请输入有效的邮箱和密码")
|
||||
return
|
||||
}
|
||||
|
||||
// 注:图形验证码在发送邮箱验证码时已验证,此处不再重复验证
|
||||
|
||||
// 验证邮箱验证码(必填)
|
||||
ctx := context.Background()
|
||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result()
|
||||
if err != nil || storedCode != req.Code {
|
||||
utils.Fail(c, "邮箱验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
|
||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
||||
|
||||
var existingUser models.User
|
||||
err = h.db.Where("email = ?", req.Email).First(&existingUser).Error
|
||||
if err == nil {
|
||||
utils.Fail(c, "该邮箱已被注册")
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "注册失败")
|
||||
return
|
||||
}
|
||||
|
||||
userCode := utils.GenerateUserCode()
|
||||
|
||||
user := models.User{
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
Nickname: "用户" + utils.GenerateRandomString(6),
|
||||
UserCode: userCode,
|
||||
}
|
||||
|
||||
err = h.db.Create(&user).Error
|
||||
if err != nil {
|
||||
global.Logger.Error("创建用户失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "注册失败")
|
||||
return
|
||||
}
|
||||
|
||||
token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID)
|
||||
if err != nil {
|
||||
utils.Fail(c, "token生成失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"token": token,
|
||||
"expiresAt": expiresAt,
|
||||
"expiresIn": utils.GetTokenExpireSeconds(),
|
||||
"nickname": user.Nickname,
|
||||
"user_code": user.UserCode,
|
||||
"avatar": user.Avatar,
|
||||
})
|
||||
}
|
||||
|
||||
// EmailLogin 邮箱登录
|
||||
func (h *userHandler) EmailLogin(c *gin.Context) {
|
||||
var req EmailLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "请输入有效的邮箱和密码")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证图形验证码(必填)
|
||||
if req.CaptchaId == "" || req.Captcha == "" {
|
||||
utils.Fail(c, "请输入图形验证码")
|
||||
return
|
||||
}
|
||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
||||
utils.Fail(c, "图形验证码错误")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, "邮箱或密码错误")
|
||||
} else {
|
||||
utils.Fail(c, "登录失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if user.Password == "" {
|
||||
utils.Fail(c, "该邮箱未设置密码,请先注册")
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
|
||||
if err != nil {
|
||||
utils.Fail(c, "邮箱或密码错误")
|
||||
return
|
||||
}
|
||||
|
||||
token, expiresAt, err := utils.GenerateTokenWithExpire(user.ID)
|
||||
if err != nil {
|
||||
utils.Fail(c, "登录失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"token": token,
|
||||
"expiresAt": expiresAt,
|
||||
"expiresIn": utils.GetTokenExpireSeconds(),
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"email": user.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckEmailExistsReq 检查邮箱是否存在请求
|
||||
type CheckEmailExistsReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// CheckEmailExists 检查邮箱是否已注册
|
||||
func (h *userHandler) CheckEmailExists(c *gin.Context) {
|
||||
var req CheckEmailExistsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "请输入有效的邮箱地址")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := h.db.Where("email = ?", req.Email).First(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Ok(c, gin.H{
|
||||
"exists": false,
|
||||
"hasPassword": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
utils.Fail(c, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, gin.H{
|
||||
"exists": true,
|
||||
"hasPassword": user.Password != "",
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPasswordReq 重置密码请求(未登录找回密码场景)
|
||||
type ResetPasswordReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
ConfirmPassword string `json:"confirmPassword"` // 确认密码(可选,前端已验证)
|
||||
Code string `json:"code" binding:"required,len=6"` // 邮箱验证码(必填)
|
||||
}
|
||||
|
||||
// ResetPassword 重置密码(未登录找回密码场景)
|
||||
func (h *userHandler) ResetPassword(c *gin.Context) {
|
||||
var req ResetPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "请输入有效的邮箱、密码和验证码")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码一致性(如果前端传递了 confirmPassword)
|
||||
if req.ConfirmPassword != "" && req.Password != req.ConfirmPassword {
|
||||
utils.Fail(c, "两次输入的密码不一致")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.Email).Result()
|
||||
if err != nil || storedCode != req.Code {
|
||||
utils.Fail(c, "验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
|
||||
global.Redis.Del(ctx, "email_code:"+req.Email)
|
||||
|
||||
var user models.User
|
||||
err = h.db.Where("email = ?", req.Email).First(&user).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "该邮箱未注册")
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "重置密码失败")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.Model(&user).Update("password", string(hashedPassword)).Error
|
||||
if err != nil {
|
||||
global.Logger.Error("更新密码失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "重置密码失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Ok(c, "密码重置成功")
|
||||
}
|
||||
|
||||
// ChangePasswordReq 修改密码请求(已登录场景)
|
||||
type ChangePasswordReq struct {
|
||||
Password string `json:"password" binding:"required,min=6"` // 新密码
|
||||
ConfirmPassword string `json:"confirmPassword" binding:"required"` // 确认密码
|
||||
NewEmail string `json:"newEmail" binding:"omitempty,email"` // 新邮箱(可选)
|
||||
CaptchaId string `json:"captchaId" binding:"required"` // 图形验证码ID
|
||||
Captcha string `json:"captcha" binding:"required"` // 图形验证码
|
||||
Code string `json:"code" binding:"omitempty,len=6"` // 邮箱验证码(修改邮箱时必填)
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码(已登录场景)
|
||||
// 支持两种场景:
|
||||
// 1. 只修改密码(不修改邮箱):只需图形验证码
|
||||
// 2. 修改邮箱+密码:需要新邮箱的验证码
|
||||
func (h *userHandler) ChangePassword(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req ChangePasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "请输入有效的参数")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 验证图形验证码(必填)
|
||||
if !store.Verify(req.CaptchaId, req.Captcha, true) {
|
||||
utils.Fail(c, "图形验证码错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 验证密码一致性
|
||||
if req.Password != req.ConfirmPassword {
|
||||
utils.Fail(c, "两次输入的密码不一致")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 3. 如果提供了新邮箱,需要验证邮箱验证码
|
||||
if req.NewEmail != "" {
|
||||
if req.Code == "" {
|
||||
utils.Fail(c, "修改邮箱需要邮箱验证码")
|
||||
return
|
||||
}
|
||||
|
||||
storedCode, err := global.Redis.Get(ctx, "email_code:"+req.NewEmail).Result()
|
||||
if err != nil || storedCode != req.Code {
|
||||
utils.Fail(c, "邮箱验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
|
||||
global.Redis.Del(ctx, "email_code:"+req.NewEmail)
|
||||
|
||||
// 检查新邮箱是否已被其他用户使用
|
||||
var existingUser models.User
|
||||
err = h.db.Where("email = ? AND id != ?", req.NewEmail, userId).First(&existingUser).Error
|
||||
if err == nil {
|
||||
utils.Fail(c, "该邮箱已被其他用户使用")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新密码
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
global.Logger.Error("密码加密失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "修改密码失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建更新数据
|
||||
updateData := map[string]interface{}{
|
||||
"password": string(hashedPassword),
|
||||
}
|
||||
|
||||
// 如果提供了新邮箱,同时更新邮箱
|
||||
if req.NewEmail != "" {
|
||||
updateData["email"] = req.NewEmail
|
||||
}
|
||||
|
||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updateData).Error
|
||||
if err != nil {
|
||||
global.Logger.Error("更新用户信息失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "修改失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 返回成功消息
|
||||
if req.NewEmail != "" {
|
||||
utils.Ok(c, "邮箱和密码修改成功")
|
||||
} else {
|
||||
utils.Ok(c, "密码修改成功")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 6. 微信绑定接口 --------------------------
|
||||
|
||||
// BindWechatReq 绑定微信请求(Web端已登录)
|
||||
type BindWechatReq struct {
|
||||
Code string `json:"code" binding:"required"` // 微信网页授权code
|
||||
}
|
||||
|
||||
// BindWechat 绑定微信(已登录场景,Web端使用)
|
||||
// 流程:用户在Web端已登录 → 扫描微信二维码 → 微信回传code → 调用此接口绑定openid
|
||||
func (h *userHandler) BindWechat(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req BindWechatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Code == "" {
|
||||
utils.Fail(c, "授权code不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 调用微信网页授权接口获取 openid
|
||||
wxResp, err := utils.WebCode2Session(req.Code)
|
||||
if err != nil {
|
||||
global.Logger.Error("微信网页授权失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "微信授权失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if wxResp.OpenID == "" {
|
||||
utils.Fail(c, "获取微信用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该 openid 是否已被其他用户绑定
|
||||
var existingUser models.User
|
||||
err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error
|
||||
if err == nil {
|
||||
utils.Fail(c, "该微信账号已被其他用户绑定")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查当前用户是否已绑定微信
|
||||
var currentUser models.User
|
||||
err = h.db.First(¤tUser, userId).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
if currentUser.Openid != "" {
|
||||
utils.Fail(c, "您已绑定微信,请先解绑")
|
||||
return
|
||||
}
|
||||
|
||||
// 绑定微信 openid 到当前用户
|
||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Update("openid", wxResp.OpenID).Error
|
||||
if err != nil {
|
||||
global.Logger.Error("绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "绑定微信失败")
|
||||
return
|
||||
}
|
||||
|
||||
global.Logger.Info("微信绑定成功", append(utils.LogContextFields(c),
|
||||
zap.Uint("userID", userId.(uint)),
|
||||
zap.String("openid", wxResp.OpenID),
|
||||
)...)
|
||||
|
||||
utils.Ok(c, "微信绑定成功")
|
||||
}
|
||||
|
||||
// -------------------------- 7. 小程序微信绑定接口 --------------------------
|
||||
|
||||
// BindWechatMiniReq 绑定微信请求(小程序端已登录)
|
||||
type BindWechatMiniReq struct {
|
||||
Code string `json:"code" binding:"required"` // 小程序 wx.login() 返回的临时code
|
||||
}
|
||||
|
||||
// BindWechatMini 小程序绑定微信(已登录场景)
|
||||
// 流程:小程序用户用邮箱注册并登录 → 点击"绑定微信" → wx.login() 获取code → 调用此接口绑定openid
|
||||
//
|
||||
// 与 Web 端 BindWechat 的关键区别:
|
||||
//
|
||||
// - Web 端:code 来自微信网页OAuth2.0扫码授权 → 调用 sns/oauth2/access_token 换取 → WebCode2Session
|
||||
//
|
||||
// - 小程序端:code 来自 wx.login() → 调用 sns/jscode2session 换取 → MiniProgramCode2Session
|
||||
//
|
||||
// 两者 code 完全不同,API 端点不同,不可混用!
|
||||
func (h *userHandler) BindWechatMini(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req BindWechatMiniReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Code == "" {
|
||||
utils.Fail(c, "授权code不能为空,请先调用wx.login获取")
|
||||
return
|
||||
}
|
||||
|
||||
// 调用小程序专用接口:sns/jscode2session
|
||||
wxResp, err := utils.MiniProgramCode2Session(req.Code)
|
||||
if err != nil {
|
||||
global.Logger.Error("小程序微信授权失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "微信授权失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
if wxResp.OpenID == "" {
|
||||
utils.Fail(c, "获取微信用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该 openid 是否已被其他用户绑定
|
||||
var existingUser models.User
|
||||
err = h.db.Where("openid = ? AND id != ?", wxResp.OpenID, userId).First(&existingUser).Error
|
||||
if err == nil {
|
||||
utils.Fail(c, "该微信账号已被其他用户绑定")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查当前用户是否已绑定微信
|
||||
var currentUser models.User
|
||||
err = h.db.First(¤tUser, userId).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
if currentUser.Openid != "" {
|
||||
utils.Fail(c, "您已绑定微信,请先解绑")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 openid 和相关字段
|
||||
updates := map[string]interface{}{
|
||||
"openid": wxResp.OpenID,
|
||||
}
|
||||
// 若存在 unionid 一并保存(跨平台识别用)
|
||||
if wxResp.UnionID != "" {
|
||||
updates["unionid"] = wxResp.UnionID
|
||||
}
|
||||
|
||||
err = h.db.Model(&models.User{}).Where("id = ?", userId).Updates(updates).Error
|
||||
if err != nil {
|
||||
global.Logger.Error("小程序绑定微信失败", append(utils.LogContextFields(c), zap.Error(err))...)
|
||||
utils.Fail(c, "绑定微信失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
|
||||
global.Logger.Info("小程序微信绑定成功", append(utils.LogContextFields(c),
|
||||
zap.Uint("userID", userId.(uint)),
|
||||
zap.String("openid", wxResp.OpenID),
|
||||
)...)
|
||||
|
||||
utils.Ok(c, "微信绑定成功")
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 小程序用户接口
|
||||
package app
|
||||
|
||||
import (
|
||||
"simple-memo/global"
|
||||
"simple-memo/models"
|
||||
"simple-memo/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// -------------------------- 1. 定义 Handler 接口 --------------------------
|
||||
// UserConfigHandler 用户模块接口定义
|
||||
type UserConfigHandler interface {
|
||||
UserConfigInfo(c *gin.Context) // 获取配置
|
||||
EditUserConfig(c *gin.Context) // 编辑配置
|
||||
}
|
||||
|
||||
// -------------------------- 2. 实现结构体(依赖注入DB) --------------------------
|
||||
// userHandler 接口实现结构体
|
||||
type userConfigHandler struct {
|
||||
db *gorm.DB // 注入数据库,方便测试
|
||||
}
|
||||
|
||||
// NewUserConfigHandler 创建用户处理器(对外暴露)
|
||||
func NewUserConfigHandler() UserConfigHandler {
|
||||
return &userConfigHandler{
|
||||
db: global.DB,
|
||||
}
|
||||
}
|
||||
|
||||
type EditUserConfigReq struct {
|
||||
Theme string `json:"theme"` // 主题
|
||||
}
|
||||
|
||||
// -------------------------- 4. 接口实现 --------------------------
|
||||
|
||||
// UserConfigInfo 获取用户配置信息
|
||||
func (h *userConfigHandler) UserConfigInfo(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.UserConfig
|
||||
err := h.db.Where("user_id = ?", userId).First(&user).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回脱敏用户信息
|
||||
utils.Ok(c, gin.H{
|
||||
"id": user.ID,
|
||||
"theme": user.Theme,
|
||||
"createdAt": user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// EditUser 修改用户信息
|
||||
func (h *userConfigHandler) EditUserConfig(c *gin.Context) {
|
||||
userId, exists := c.Get("userID")
|
||||
if !exists {
|
||||
utils.Fail(c, "用户未登录")
|
||||
}
|
||||
var editUserConfigReq EditUserConfigReq
|
||||
if err := c.ShouldBindJSON(&editUserConfigReq); err != nil {
|
||||
utils.Fail(c, "参数解析失败")
|
||||
}
|
||||
var data map[string]any
|
||||
// 如果只存在一个字段,则只更新当前的一个
|
||||
if editUserConfigReq.Theme != "" {
|
||||
data = map[string]any{
|
||||
"user_id": userId,
|
||||
"theme": editUserConfigReq.Theme,
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
utils.Ok(c, nil)
|
||||
}
|
||||
|
||||
err := h.db.Where("user_id = ?", userId).
|
||||
Assign(data).
|
||||
FirstOrCreate(&models.UserConfig{}).Error
|
||||
if err != nil {
|
||||
utils.Fail(c, "用户配置信息更新失败")
|
||||
}
|
||||
utils.Ok(c, nil)
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user