init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台
- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导), 服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar - pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页, 微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台 - 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
+24
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-WALewLI5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-ZqmdrDyd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Register 把内嵌的后台 SPA 挂到 /admin 下,index.html 做 SPA fallback
|
||||
func Register(r *gin.Engine) {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
// 去掉前缀 /admin,交给内嵌文件服务器
|
||||
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/admin")
|
||||
reqPath = strings.TrimPrefix(reqPath, "/")
|
||||
|
||||
// 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由)
|
||||
if reqPath != "" {
|
||||
if f, err := sub.Open(reqPath); err == nil {
|
||||
_ = f.Close()
|
||||
c.Request.URL.Path = "/" + reqPath
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
// SPA fallback
|
||||
data, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "admin dist missing")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
r.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/") })
|
||||
r.GET("/admin/*filepath", handler)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package ai 提供 provider 中立的大模型调用能力。
|
||||
// 目前内置 openai 兼容 provider(适配 DeepSeek / Qwen 兼容模式 / Gemini 兼容端点等),
|
||||
// 以及规则化 mock。新增其它厂商只需实现 Provider 接口。
|
||||
package ai
|
||||
|
||||
import (
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// Message 一条对话消息
|
||||
type Message struct {
|
||||
Role string // system / user / assistant
|
||||
Content string
|
||||
}
|
||||
|
||||
// Options 单次调用参数
|
||||
type Options struct {
|
||||
JSON bool // 要求返回 JSON(结构化输出)
|
||||
MaxTokens int // 0 用配置默认
|
||||
Temperature float64 // <0 用配置默认
|
||||
}
|
||||
|
||||
// Provider 大模型供应商接口
|
||||
type Provider interface {
|
||||
Name() string
|
||||
// Complete 传入 system 提示与多轮消息,返回助手回复文本
|
||||
Complete(system string, messages []Message, opts Options) (string, error)
|
||||
}
|
||||
|
||||
// Engine 对外统一入口,持有当前 provider 与开关
|
||||
type Engine struct {
|
||||
provider Provider
|
||||
cfg config.AIConfig
|
||||
}
|
||||
|
||||
// New 根据配置构建引擎。未启用或缺 key 时 Enabled()=false,业务侧回退规则化文案。
|
||||
func New(cfg config.AIConfig) *Engine {
|
||||
e := &Engine{cfg: cfg}
|
||||
if !cfg.Enabled || cfg.APIKey == "" {
|
||||
return e
|
||||
}
|
||||
switch cfg.Provider {
|
||||
case "", "openai":
|
||||
e.provider = newOpenAICompatProvider(cfg)
|
||||
default:
|
||||
// 未知 provider 视为未启用,回退规则化
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Enabled 是否可用真实模型
|
||||
func (e *Engine) Enabled() bool {
|
||||
return e.provider != nil
|
||||
}
|
||||
|
||||
// ProviderName 当前供应商名(诊断用)
|
||||
func (e *Engine) ProviderName() string {
|
||||
if e.provider == nil {
|
||||
return "disabled"
|
||||
}
|
||||
return e.provider.Name()
|
||||
}
|
||||
|
||||
// Complete 代理到当前 provider(调用前请先判断 Enabled)
|
||||
func (e *Engine) Complete(system string, messages []Message, opts Options) (string, error) {
|
||||
if opts.MaxTokens == 0 {
|
||||
opts.MaxTokens = e.cfg.MaxTokens
|
||||
}
|
||||
if opts.Temperature < 0 {
|
||||
opts.Temperature = e.cfg.Temperature
|
||||
}
|
||||
return e.provider.Complete(system, messages, opts)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// openaiCompatProvider 走 OpenAI 兼容的 /chat/completions 协议。
|
||||
// DeepSeek、Qwen(dashscope 兼容模式)、Moonshot、Gemini(OpenAI 兼容端点) 等均可用。
|
||||
type openaiCompatProvider struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newOpenAICompatProvider(cfg config.AIConfig) *openaiCompatProvider {
|
||||
timeout := time.Duration(cfg.TimeoutSec) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &openaiCompatProvider{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *openaiCompatProvider) Name() string { return "openai-compat:" + p.model }
|
||||
|
||||
type chatReq struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMsg `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
ResponseFormat *respFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type respFormat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (p *openaiCompatProvider) Complete(system string, messages []Message, opts Options) (string, error) {
|
||||
msgs := make([]chatMsg, 0, len(messages)+1)
|
||||
if system != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "system", Content: system})
|
||||
}
|
||||
for _, m := range messages {
|
||||
msgs = append(msgs, chatMsg{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
body := chatReq{
|
||||
Model: p.model,
|
||||
Messages: msgs,
|
||||
Temperature: opts.Temperature,
|
||||
MaxTokens: opts.MaxTokens,
|
||||
}
|
||||
if opts.JSON {
|
||||
body.ResponseFormat = &respFormat{Type: "json_object"}
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.client.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
var out chatResp
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", fmt.Errorf("ai 响应解析失败: %s", truncate(string(data), 200))
|
||||
}
|
||||
if out.Error != nil {
|
||||
return "", fmt.Errorf("ai 供应商错误: %s", out.Error.Message)
|
||||
}
|
||||
if resp.StatusCode >= 400 || len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("ai 调用失败(%d): %s", resp.StatusCode, truncate(string(data), 200))
|
||||
}
|
||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config 全局配置
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
MySQL MySQLConfig `mapstructure:"mysql"`
|
||||
MinIO MinIOConfig `mapstructure:"minio"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
WeChat WeChatConfig `mapstructure:"wechat"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
Admin AdminConfig `mapstructure:"admin"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
}
|
||||
|
||||
// AIConfig 大模型配置(provider 中立,openai 兼容 base_url)
|
||||
type AIConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Provider string `mapstructure:"provider"` // openai(兼容) / mock
|
||||
BaseURL string `mapstructure:"base_url"` // 如 https://api.deepseek.com
|
||||
APIKey string `mapstructure:"api_key"` // 用 PETS_AI_API_KEY 覆盖
|
||||
Model string `mapstructure:"model"` // 如 deepseek-chat
|
||||
Temperature float64 `mapstructure:"temperature"`
|
||||
MaxTokens int `mapstructure:"max_tokens"`
|
||||
TimeoutSec int `mapstructure:"timeout_sec"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
Charset string `mapstructure:"charset"`
|
||||
}
|
||||
|
||||
// DSN 返回 GORM MySQL 连接串
|
||||
func (m MySQLConfig) DSN() string {
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
|
||||
m.User, m.Password, m.Host, m.Port, m.Database, m.Charset,
|
||||
)
|
||||
}
|
||||
|
||||
type MinIOConfig struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
}
|
||||
|
||||
type WeChatConfig struct {
|
||||
AppID string `mapstructure:"app_id"`
|
||||
AppSecret string `mapstructure:"app_secret"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
DevLogin bool `mapstructure:"dev_login"`
|
||||
}
|
||||
|
||||
type AdminConfig struct {
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// Load 载入配置:configs/config.yaml + 环境变量覆盖(PETS_ 前缀,点转下划线)
|
||||
// 例如 PETS_MYSQL_PASSWORD 覆盖 mysql.password
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath("./configs")
|
||||
v.AddConfigPath("../../configs")
|
||||
v.AddConfigPath(".")
|
||||
|
||||
v.SetEnvPrefix("PETS")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// New 建立 GORM 连接,所有表名统一加 sundynix_ 前缀
|
||||
func New(cfg *config.Config) (*gorm.DB, error) {
|
||||
logLevel := logger.Info
|
||||
if cfg.Server.Mode == "release" {
|
||||
logLevel = logger.Warn
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN()), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "sundynix_",
|
||||
SingularTable: false,
|
||||
},
|
||||
Logger: logger.Default.LogMode(logLevel),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gorm open: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate 自动建表
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(model.AllModels()...)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// Seed 初始化种子数据:管理员账号 + 新手文章
|
||||
func Seed(db *gorm.DB, cfg *config.Config) error {
|
||||
if err := seedAdmin(db, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return seedArticles(db)
|
||||
}
|
||||
|
||||
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
|
||||
var count int64
|
||||
if err := db.Model(&model.Admin{}).Where("username = ?", cfg.Admin.Username).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Admin.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&model.Admin{
|
||||
Username: cfg.Admin.Username,
|
||||
PasswordHash: string(hash),
|
||||
Role: "admin",
|
||||
}).Error
|
||||
}
|
||||
|
||||
func seedArticles(db *gorm.DB) error {
|
||||
var count int64
|
||||
if err := db.Model(&model.Article{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
articles := []model.Article{
|
||||
{Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?", Description: "适合搜索流量和社群传播,引导生成提醒。", Category: "vaccine", RelatedSheetType: "vaccine", Published: true},
|
||||
{Icon: "💩", Title: "猫咪软便要不要去医院?", Description: "接异常记录和 AI 观察建议。", Category: "symptom", RelatedSheetType: "symptom", Published: true},
|
||||
{Icon: "🍽️", Title: "7 天换粮计划怎么做?", Description: "接计划模板,适合一次性付费。", Category: "food", RelatedSheetType: "applyPlan", Published: true},
|
||||
{Icon: "💰", Title: "一个月养猫大概花多少钱?", Description: "接养宠账本和年度账单。", Category: "cost", RelatedSheetType: "cost", Published: true},
|
||||
}
|
||||
return db.Create(&articles).Error
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type adminLoginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AdminLogin POST /api/admin/login
|
||||
func (h *Handler) AdminLogin(c *gin.Context) {
|
||||
var req adminLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
admin, err := h.svc.AdminLogin(req.Username, req.Password)
|
||||
if err != nil {
|
||||
response.Fail(c, 40100, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": token, "admin": admin})
|
||||
}
|
||||
|
||||
// AdminMe GET /api/admin/me
|
||||
func (h *Handler) AdminMe(c *gin.Context) {
|
||||
admin, err := h.svc.GetAdmin(middleware.AdminID(c))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, admin)
|
||||
}
|
||||
|
||||
// AdminStats GET /api/admin/stats
|
||||
func (h *Handler) AdminStats(c *gin.Context) {
|
||||
stats, err := h.svc.Stats()
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, stats)
|
||||
}
|
||||
|
||||
type adminListReq struct {
|
||||
response.PageQuery
|
||||
Keyword string `form:"keyword"`
|
||||
Status string `form:"status"`
|
||||
}
|
||||
|
||||
// AdminListUsers GET /api/admin/users
|
||||
func (h *Handler) AdminListUsers(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
users, total, err := h.svc.ListUsers(req.Keyword, req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(users, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type disableReq struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// AdminSetUserDisabled PUT /api/admin/users/:id/disabled
|
||||
func (h *Handler) AdminSetUserDisabled(c *gin.Context) {
|
||||
var req disableReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetUserDisabled(uintParam(c, "id"), req.Disabled); err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListPets GET /api/admin/pets
|
||||
func (h *Handler) AdminListPets(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
pets, total, err := h.svc.ListPetsAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(pets, total, req.PageQuery))
|
||||
}
|
||||
|
||||
// AdminListPosts GET /api/admin/posts
|
||||
func (h *Handler) AdminListPosts(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
posts, total, err := h.svc.ListPostsAdmin(req.Status, req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(posts, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type postStatusReq struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AdminSetPostStatus PUT /api/admin/posts/:id/status
|
||||
func (h *Handler) AdminSetPostStatus(c *gin.Context) {
|
||||
var req postStatusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetPostStatus(uintParam(c, "id"), req.Status); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListComments GET /api/admin/comments
|
||||
func (h *Handler) AdminListComments(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
comments, total, err := h.svc.ListCommentsAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
||||
}
|
||||
|
||||
// AdminDeleteComment DELETE /api/admin/comments/:id
|
||||
func (h *Handler) AdminDeleteComment(c *gin.Context) {
|
||||
if err := h.svc.DeleteCommentAdmin(uintParam(c, "id")); err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListArticles GET /api/admin/articles
|
||||
func (h *Handler) AdminListArticles(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
articles, total, err := h.svc.ListArticlesAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(articles, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type articleReq struct {
|
||||
ID uint `json:"id"`
|
||||
Icon string `json:"icon"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
RelatedSheetType string `json:"related_sheet_type"`
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
// AdminSaveArticle POST /api/admin/articles(id>0 为更新)
|
||||
func (h *Handler) AdminSaveArticle(c *gin.Context) {
|
||||
var req articleReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
article := &model.Article{
|
||||
Icon: req.Icon, Title: req.Title, Description: req.Description, Content: req.Content,
|
||||
Category: req.Category, RelatedSheetType: req.RelatedSheetType, Published: req.Published,
|
||||
}
|
||||
article.ID = req.ID
|
||||
if err := h.svc.SaveArticle(article); err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, article)
|
||||
}
|
||||
|
||||
// AdminDeleteArticle DELETE /api/admin/articles/:id
|
||||
func (h *Handler) AdminDeleteArticle(c *gin.Context) {
|
||||
if err := h.svc.DeleteArticle(uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListPro GET /api/admin/memberships
|
||||
func (h *Handler) AdminListPro(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
list, total, err := h.svc.ListProAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(list, total, req.PageQuery))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type loginReq struct {
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
// Login 开发态 Mock 登录
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
if !h.cfg.Auth.DevLogin {
|
||||
response.Fail(c, 40300, "开发登录未开启,请使用微信登录")
|
||||
return
|
||||
}
|
||||
var req loginReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
user, err := h.svc.MockLogin(req.Nickname)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": token, "user": user})
|
||||
}
|
||||
|
||||
type wechatLoginReq struct {
|
||||
Code string `json:"code"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// WechatLogin 微信小程序登录:前端 wx.login 拿 code 传入
|
||||
func (h *Handler) WechatLogin(c *gin.Context) {
|
||||
var req wechatLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Code == "" {
|
||||
response.FailParams(c, "缺少 code")
|
||||
return
|
||||
}
|
||||
user, err := h.svc.WechatLogin(req.Code)
|
||||
if err != nil {
|
||||
response.Fail(c, 40100, err.Error())
|
||||
return
|
||||
}
|
||||
// 可选:首次登录写入用户授权的昵称/头像
|
||||
if fields := map[string]any{}; true {
|
||||
if req.Nickname != "" && user.Nickname == "微信用户" {
|
||||
fields["nickname"] = req.Nickname
|
||||
}
|
||||
if req.Avatar != "" && user.Avatar == "" {
|
||||
fields["avatar"] = req.Avatar
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if u, e := h.svc.UpdateUser(user.ID, fields); e == nil {
|
||||
user = u
|
||||
}
|
||||
}
|
||||
}
|
||||
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": token, "user": user})
|
||||
}
|
||||
|
||||
// Profile 当前用户资料
|
||||
func (h *Handler) Profile(c *gin.Context) {
|
||||
user, err := h.svc.GetUser(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, user)
|
||||
}
|
||||
|
||||
type updateProfileReq struct {
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Phone *string `json:"phone"`
|
||||
}
|
||||
|
||||
// UpdateProfile 更新资料
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
var req updateProfileReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Nickname != nil {
|
||||
fields["nickname"] = *req.Nickname
|
||||
}
|
||||
if req.Avatar != nil {
|
||||
fields["avatar"] = *req.Avatar
|
||||
}
|
||||
if req.Phone != nil {
|
||||
fields["phone"] = *req.Phone
|
||||
}
|
||||
user, err := h.svc.UpdateUser(middleware.UserID(c), fields)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, user)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type postListReq struct {
|
||||
response.PageQuery
|
||||
Tab string `form:"tab"`
|
||||
}
|
||||
|
||||
// ListPosts GET /api/posts?tab=&page=&page_size=
|
||||
func (h *Handler) ListPosts(c *gin.Context) {
|
||||
var req postListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
|
||||
posts, total, err := h.svc.ListPosts(req.Tab, req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(posts, total, req.PageQuery))
|
||||
}
|
||||
|
||||
// GetPost GET /api/posts/:id
|
||||
func (h *Handler) GetPost(c *gin.Context) {
|
||||
post, err := h.svc.GetPost(uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, post)
|
||||
}
|
||||
|
||||
type postReq struct {
|
||||
PetID *uint `json:"pet_id"`
|
||||
Identity string `json:"identity"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
// CreatePost POST /api/posts
|
||||
func (h *Handler) CreatePost(c *gin.Context) {
|
||||
var req postReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
in := service.PostInput{PetID: req.PetID, Identity: req.Identity, Content: req.Content}
|
||||
if b, err := jsonMarshal(req.Tags); err == nil {
|
||||
in.Tags = datatypes.JSON(b)
|
||||
}
|
||||
if b, err := jsonMarshal(req.Images); err == nil {
|
||||
in.Images = datatypes.JSON(b)
|
||||
}
|
||||
post, err := h.svc.CreatePost(middleware.UserID(c), in)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, post)
|
||||
}
|
||||
|
||||
// LikePost POST /api/posts/:id/like
|
||||
func (h *Handler) LikePost(c *gin.Context) {
|
||||
count, err := h.svc.LikePost(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"like_count": count})
|
||||
}
|
||||
|
||||
// UnlikePost DELETE /api/posts/:id/like
|
||||
func (h *Handler) UnlikePost(c *gin.Context) {
|
||||
count, err := h.svc.UnlikePost(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"like_count": count})
|
||||
}
|
||||
|
||||
type commentListReq struct {
|
||||
response.PageQuery
|
||||
}
|
||||
|
||||
// ListComments GET /api/posts/:id/comments
|
||||
func (h *Handler) ListComments(c *gin.Context) {
|
||||
var req commentListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
|
||||
comments, total, err := h.svc.ListComments(uintParam(c, "id"), req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type commentReq struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CreateComment POST /api/posts/:id/comments
|
||||
func (h *Handler) CreateComment(c *gin.Context) {
|
||||
var req commentReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
response.FailParams(c, "评论内容不能为空")
|
||||
return
|
||||
}
|
||||
comment, err := h.svc.CreateComment(middleware.UserID(c), uintParam(c, "id"), req.Content)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, comment)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
)
|
||||
|
||||
// Handler 所有 HTTP 处理器聚合,方法按领域分散在各文件
|
||||
type Handler struct {
|
||||
svc *service.Service
|
||||
jwt *appjwt.Manager
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(svc *service.Service, jm *appjwt.Manager, cfg *config.Config) *Handler {
|
||||
return &Handler{svc: svc, jwt: jm, cfg: cfg}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/errcode"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// uintParam 解析路径参数为 uint
|
||||
func uintParam(c *gin.Context, key string) uint {
|
||||
v, _ := strconv.ParseUint(c.Param(key), 10, 64)
|
||||
return uint(v)
|
||||
}
|
||||
|
||||
// jsonMarshal 便捷序列化
|
||||
func jsonMarshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// respondErr 统一错误响应:区分 not found 与内部错误
|
||||
func respondErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
response.Fail(c, errcode.ErrNotFound, "")
|
||||
return
|
||||
}
|
||||
response.FailErr(c, err)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// ListArticles GET /api/articles
|
||||
func (h *Handler) ListArticles(c *gin.Context) {
|
||||
articles, err := h.svc.ListArticles()
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, articles)
|
||||
}
|
||||
|
||||
// GetArticle GET /api/articles/:id
|
||||
func (h *Handler) GetArticle(c *gin.Context) {
|
||||
article, err := h.svc.GetArticle(uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, article)
|
||||
}
|
||||
|
||||
// GetPro GET /api/pro
|
||||
func (h *Handler) GetPro(c *gin.Context) {
|
||||
info, err := h.svc.GetPro(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, info)
|
||||
}
|
||||
|
||||
// ActivatePro POST /api/pro/activate
|
||||
func (h *Handler) ActivatePro(c *gin.Context) {
|
||||
info, err := h.svc.ActivatePro(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, info)
|
||||
}
|
||||
|
||||
type aiChatReq struct {
|
||||
PetID *uint `json:"pet_id"`
|
||||
Session string `json:"session"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// AIChat POST /api/ai/chat
|
||||
func (h *Handler) AIChat(c *gin.Context) {
|
||||
var req aiChatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Text == "" {
|
||||
response.FailParams(c, "问题不能为空")
|
||||
return
|
||||
}
|
||||
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reply": reply})
|
||||
}
|
||||
|
||||
// HomeSummary GET /api/pets/:id/home-summary
|
||||
func (h *Handler) HomeSummary(c *gin.Context) {
|
||||
res, err := h.svc.GetHomeSummary(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// UserSummary GET /api/user/summary
|
||||
func (h *Handler) UserSummary(c *gin.Context) {
|
||||
res, err := h.svc.GetUserSummary(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
type assessSymptomReq struct {
|
||||
Symptoms []string `json:"symptoms"`
|
||||
Duration string `json:"duration"`
|
||||
Spirit string `json:"spirit"`
|
||||
}
|
||||
|
||||
// AssessSymptom POST /api/pets/:id/ai/assess-symptom
|
||||
func (h *Handler) AssessSymptom(c *gin.Context) {
|
||||
var req assessSymptomReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.AssessSymptom(middleware.UserID(c), uintParam(c, "id"), service.SymptomInput{
|
||||
Symptoms: req.Symptoms, Duration: req.Duration, Spirit: req.Spirit,
|
||||
})
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type petReq struct {
|
||||
Name string `json:"name"`
|
||||
Emoji string `json:"emoji"`
|
||||
Type string `json:"type"`
|
||||
Gender string `json:"gender"`
|
||||
Birthday string `json:"birthday"` // YYYY-MM-DD
|
||||
Weight string `json:"weight"`
|
||||
Stage string `json:"stage"`
|
||||
Age string `json:"age"`
|
||||
Color string `json:"color"`
|
||||
Breed string `json:"breed"`
|
||||
Goals []string `json:"goals"`
|
||||
}
|
||||
|
||||
func (r petReq) toInput() service.PetInput {
|
||||
in := service.PetInput{
|
||||
Name: r.Name, Emoji: r.Emoji, Type: r.Type, Gender: r.Gender,
|
||||
Weight: r.Weight, Stage: r.Stage, Age: r.Age, Color: r.Color, Breed: r.Breed,
|
||||
}
|
||||
if r.Birthday != "" {
|
||||
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
|
||||
in.Birthday = &t
|
||||
}
|
||||
}
|
||||
if r.Goals != nil {
|
||||
if b, err := jsonMarshal(r.Goals); err == nil {
|
||||
in.Goals = datatypes.JSON(b)
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// ListPets GET /api/pets
|
||||
func (h *Handler) ListPets(c *gin.Context) {
|
||||
pets, err := h.svc.ListPets(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pets)
|
||||
}
|
||||
|
||||
// CreatePet POST /api/pets
|
||||
func (h *Handler) CreatePet(c *gin.Context) {
|
||||
var req petReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
pet, err := h.svc.CreatePet(middleware.UserID(c), req.toInput())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
|
||||
// GetPet GET /api/pets/:id
|
||||
func (h *Handler) GetPet(c *gin.Context) {
|
||||
pet, err := h.svc.GetPet(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
|
||||
// UpdatePet PUT /api/pets/:id
|
||||
func (h *Handler) UpdatePet(c *gin.Context) {
|
||||
var req petReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Name != "" {
|
||||
fields["name"] = req.Name
|
||||
}
|
||||
if req.Emoji != "" {
|
||||
fields["emoji"] = req.Emoji
|
||||
}
|
||||
if req.Weight != "" {
|
||||
fields["weight"] = req.Weight
|
||||
}
|
||||
if req.Stage != "" {
|
||||
fields["stage"] = req.Stage
|
||||
}
|
||||
if req.Gender != "" {
|
||||
fields["gender"] = req.Gender
|
||||
}
|
||||
if req.Age != "" {
|
||||
fields["age"] = req.Age
|
||||
}
|
||||
if req.Color != "" {
|
||||
fields["color"] = req.Color
|
||||
}
|
||||
if req.Breed != "" {
|
||||
fields["breed"] = req.Breed
|
||||
}
|
||||
pet, err := h.svc.UpdatePet(middleware.UserID(c), uintParam(c, "id"), fields)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
|
||||
// DeletePet DELETE /api/pets/:id
|
||||
func (h *Handler) DeletePet(c *gin.Context) {
|
||||
if err := h.svc.DeletePet(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// Onboarding POST /api/onboarding
|
||||
func (h *Handler) Onboarding(c *gin.Context) {
|
||||
var req petReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
pet, err := h.svc.Onboarding(middleware.UserID(c), req.toInput())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// ListTasks GET /api/pets/:id/tasks?date=YYYY-MM-DD
|
||||
func (h *Handler) ListTasks(c *gin.Context) {
|
||||
var date *time.Time
|
||||
if q := c.Query("date"); q != "" {
|
||||
if t, err := time.Parse("2006-01-02", q); err == nil {
|
||||
date = &t
|
||||
}
|
||||
}
|
||||
tasks, err := h.svc.ListTasks(middleware.UserID(c), uintParam(c, "id"), date)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, tasks)
|
||||
}
|
||||
|
||||
// ToggleTask POST /api/tasks/:id/toggle
|
||||
func (h *Handler) ToggleTask(c *gin.Context) {
|
||||
task, err := h.svc.ToggleTask(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, task)
|
||||
}
|
||||
|
||||
// CompleteAllTasks POST /api/pets/:id/tasks/complete-all
|
||||
func (h *Handler) CompleteAllTasks(c *gin.Context) {
|
||||
tasks, err := h.svc.CompleteAllTasks(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, tasks)
|
||||
}
|
||||
|
||||
// GetPlan GET /api/pets/:id/plan
|
||||
func (h *Handler) GetPlan(c *gin.Context) {
|
||||
plan, err := h.svc.GetPlan(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
// PlanCalendar GET /api/pets/:id/plan/calendar?month=YYYY-MM
|
||||
func (h *Handler) PlanCalendar(c *gin.Context) {
|
||||
now := time.Now()
|
||||
year, month := now.Year(), int(now.Month())
|
||||
if q := c.Query("month"); q != "" {
|
||||
if t, err := time.Parse("2006-01", q); err == nil {
|
||||
year, month = t.Year(), int(t.Month())
|
||||
}
|
||||
}
|
||||
res, err := h.svc.Calendar(middleware.UserID(c), uintParam(c, "id"), year, month)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
type aiPlanReq struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
// CreateAIPlan POST /api/pets/:id/ai-plan
|
||||
func (h *Handler) CreateAIPlan(c *gin.Context) {
|
||||
var req aiPlanReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
plan, err := h.svc.CreateAIPlan(middleware.UserID(c), uintParam(c, "id"), req.Input)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
// ApplyAIPlan POST /api/ai-plan/:id/apply
|
||||
func (h *Handler) ApplyAIPlan(c *gin.Context) {
|
||||
plan, err := h.svc.ApplyAIPlan(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
// TogglePlanTask POST /api/plan-tasks/:id/toggle
|
||||
func (h *Handler) TogglePlanTask(c *gin.Context) {
|
||||
if err := h.svc.TogglePlanTask(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type recordReq struct {
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
NumValue float64 `json:"num_value"`
|
||||
Category string `json:"category"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Extra datatypes.JSON `json:"extra"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
}
|
||||
|
||||
// ListRecords GET /api/pets/:id/records?type=
|
||||
func (h *Handler) ListRecords(c *gin.Context) {
|
||||
records, err := h.svc.ListRecords(middleware.UserID(c), uintParam(c, "id"), c.Query("type"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, records)
|
||||
}
|
||||
|
||||
// CreateRecord POST /api/pets/:id/records
|
||||
func (h *Handler) CreateRecord(c *gin.Context) {
|
||||
var req recordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
in := service.RecordInput{
|
||||
Type: req.Type, Icon: req.Icon, Title: req.Title, Description: req.Description,
|
||||
NumValue: req.NumValue, Category: req.Category, ImageURL: req.ImageURL, Extra: req.Extra,
|
||||
}
|
||||
if req.OccurredAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, req.OccurredAt); err == nil {
|
||||
in.OccurredAt = &t
|
||||
}
|
||||
}
|
||||
rec, err := h.svc.CreateRecord(middleware.UserID(c), uintParam(c, "id"), in)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, rec)
|
||||
}
|
||||
|
||||
// DeleteRecord DELETE /api/records/:id
|
||||
func (h *Handler) DeleteRecord(c *gin.Context) {
|
||||
if err := h.svc.DeleteRecord(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// WeightTrend GET /api/pets/:id/records/weight-trend
|
||||
func (h *Handler) WeightTrend(c *gin.Context) {
|
||||
points, err := h.svc.WeightTrend(middleware.UserID(c), uintParam(c, "id"), 7)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, points)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type reminderReq struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
NextDueDate string `json:"next_due_date"` // YYYY-MM-DD
|
||||
Frequency string `json:"frequency"`
|
||||
}
|
||||
|
||||
// ListReminders GET /api/pets/:id/reminders
|
||||
func (h *Handler) ListReminders(c *gin.Context) {
|
||||
reminders, err := h.svc.ListReminders(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, reminders)
|
||||
}
|
||||
|
||||
// CreateReminder POST /api/pets/:id/reminders
|
||||
func (h *Handler) CreateReminder(c *gin.Context) {
|
||||
var req reminderReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
|
||||
if req.NextDueDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
|
||||
in.NextDueDate = &t
|
||||
}
|
||||
}
|
||||
r, err := h.svc.CreateReminder(middleware.UserID(c), uintParam(c, "id"), in)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, r)
|
||||
}
|
||||
|
||||
// UpdateReminder PUT /api/reminders/:id
|
||||
func (h *Handler) UpdateReminder(c *gin.Context) {
|
||||
var req reminderReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Title != "" {
|
||||
fields["title"] = req.Title
|
||||
}
|
||||
if req.Frequency != "" {
|
||||
fields["frequency"] = req.Frequency
|
||||
}
|
||||
if req.NextDueDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
|
||||
fields["next_due_date"] = t
|
||||
}
|
||||
}
|
||||
r, err := h.svc.UpdateReminder(middleware.UserID(c), uintParam(c, "id"), fields)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, r)
|
||||
}
|
||||
|
||||
// DeleteReminder DELETE /api/reminders/:id
|
||||
func (h *Handler) DeleteReminder(c *gin.Context) {
|
||||
if err := h.svc.DeleteReminder(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// WeeklyReport GET /api/pets/:id/report/weekly
|
||||
func (h *Handler) WeeklyReport(c *gin.Context) {
|
||||
rep, err := h.svc.GetWeeklyReport(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, rep)
|
||||
}
|
||||
|
||||
// Bill GET /api/pets/:id/bill?period=month
|
||||
func (h *Handler) Bill(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "month")
|
||||
bill, err := h.svc.GetBill(middleware.UserID(c), uintParam(c, "id"), period)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, bill)
|
||||
}
|
||||
|
||||
// HealthSummary GET /api/pets/:id/health-summary
|
||||
func (h *Handler) HealthSummary(c *gin.Context) {
|
||||
sum, err := h.svc.GetHealthSummary(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, sum)
|
||||
}
|
||||
|
||||
// Poster GET /api/pets/:id/poster
|
||||
func (h *Handler) Poster(c *gin.Context) {
|
||||
poster, err := h.svc.GetPoster(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, poster)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// Upload POST /api/upload multipart/form-data,字段名 file
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.FailParams(c, "缺少上传文件 file")
|
||||
return
|
||||
}
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
ext := strings.ToLower(path.Ext(fileHeader.Filename))
|
||||
objectName := fmt.Sprintf("uploads/%d/%d%s", middleware.UserID(c), time.Now().UnixNano(), ext)
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
url, err := h.svc.Upload(objectName, src, fileHeader.Size, contentType)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"url": url})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/pkg/errcode"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// gin.Context 中存放身份的 key
|
||||
const (
|
||||
CtxUserID = "userID"
|
||||
CtxAdminID = "adminID"
|
||||
CtxName = "name"
|
||||
)
|
||||
|
||||
func bearer(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// AuthUser 小程序用户鉴权
|
||||
func AuthUser(jm *appjwt.Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := jm.Parse(bearer(c))
|
||||
if err != nil || claims.Kind != appjwt.KindUser {
|
||||
response.Abort(c, errcode.ErrUnauthized, "")
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, claims.ID)
|
||||
c.Set(CtxName, claims.Name)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AuthAdmin 后台管理员鉴权
|
||||
func AuthAdmin(jm *appjwt.Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := jm.Parse(bearer(c))
|
||||
if err != nil || claims.Kind != appjwt.KindAdmin {
|
||||
response.Abort(c, errcode.ErrUnauthized, "")
|
||||
return
|
||||
}
|
||||
c.Set(CtxAdminID, claims.ID)
|
||||
c.Set(CtxName, claims.Name)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UserID 从上下文取当前用户 ID
|
||||
func UserID(c *gin.Context) uint {
|
||||
if v, ok := c.Get(CtxUserID); ok {
|
||||
if id, ok := v.(uint); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AdminID 从上下文取当前管理员 ID
|
||||
func AdminID(c *gin.Context) uint {
|
||||
if v, ok := c.Get(CtxAdminID); ok {
|
||||
if id, ok := v.(uint); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 开发期允许跨域(小程序无跨域限制,主要给后台 SPA dev server 用)
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
// Admin 后台管理员
|
||||
type Admin struct {
|
||||
Base
|
||||
Username string `gorm:"size:64;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:128" json:"-"`
|
||||
Role string `gorm:"size:32;default:admin" json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
// AIMessage AI 聊天消息
|
||||
type AIMessage struct {
|
||||
Base
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
PetID *uint `json:"pet_id"`
|
||||
Session string `gorm:"size:64;index" json:"session"`
|
||||
Role string `gorm:"size:8" json:"role"` // user / ai
|
||||
Text string `gorm:"type:text" json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
// Article 新手知识文章
|
||||
type Article struct {
|
||||
Base
|
||||
Icon string `gorm:"size:16" json:"icon"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Category string `gorm:"size:32" json:"category"`
|
||||
RelatedSheetType string `gorm:"size:32" json:"related_sheet_type"`
|
||||
Published bool `gorm:"default:true" json:"published"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Base 所有模型的公共字段
|
||||
type Base struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AllModels 需要 AutoMigrate 的模型清单(按依赖顺序)
|
||||
func AllModels() []any {
|
||||
return []any{
|
||||
&User{},
|
||||
&Pet{},
|
||||
&HealthRecord{},
|
||||
&DailyTask{},
|
||||
&Plan{},
|
||||
&PlanTask{},
|
||||
&Reminder{},
|
||||
&Post{},
|
||||
&Comment{},
|
||||
&PostLike{},
|
||||
&Article{},
|
||||
&AIMessage{},
|
||||
&ProMembership{},
|
||||
&Admin{},
|
||||
&DailyAdvice{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
// Comment 帖子评论
|
||||
type Comment struct {
|
||||
Base
|
||||
PostID uint `gorm:"index" json:"post_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
||||
Content string `gorm:"size:512" json:"content"`
|
||||
Status string `gorm:"size:16;default:published" json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
// DailyAdvice 首页「今日建议」每宠每天缓存一条,避免重复调用模型
|
||||
type DailyAdvice struct {
|
||||
Base
|
||||
PetID uint `gorm:"uniqueIndex:idx_pet_day" json:"pet_id"`
|
||||
Day string `gorm:"size:10;uniqueIndex:idx_pet_day" json:"day"` // YYYY-MM-DD
|
||||
Text string `gorm:"type:text" json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// DailyTask 今日任务
|
||||
type DailyTask struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
TaskDate time.Time `gorm:"index" json:"task_date"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
Priority string `gorm:"size:16" json:"priority"` // "" / 重要
|
||||
SheetType string `gorm:"size:32" json:"sheet_type"` // 点击打开的弹层类型
|
||||
Done bool `json:"done"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// 健康记录类型
|
||||
const (
|
||||
RecordWeight = "weight"
|
||||
RecordPoop = "poop"
|
||||
RecordFood = "food"
|
||||
RecordSymptom = "symptom"
|
||||
RecordMedicine = "medicine"
|
||||
RecordVaccine = "vaccine"
|
||||
RecordDeworm = "deworm"
|
||||
RecordCost = "cost"
|
||||
RecordPhoto = "photo"
|
||||
)
|
||||
|
||||
// HealthRecord 统一健康/时间轴记录表,type 区分 9 类。
|
||||
// NumValue 存体重/金额,Category 存消费类别/便便状态等,Extra 存各类型专有字段。
|
||||
type HealthRecord struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Type string `gorm:"size:16;index" json:"type"`
|
||||
Icon string `gorm:"size:16" json:"icon"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
NumValue float64 `gorm:"type:decimal(10,2)" json:"num_value"`
|
||||
Category string `gorm:"size:32" json:"category"`
|
||||
ImageURL string `gorm:"size:512" json:"image_url"`
|
||||
Extra datatypes.JSON `json:"extra"`
|
||||
OccurredAt time.Time `gorm:"index" json:"occurred_at"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// Pet 宠物,属于 User,支持多宠
|
||||
type Pet struct {
|
||||
Base
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Name string `gorm:"size:64" json:"name"`
|
||||
Emoji string `gorm:"size:16" json:"emoji"`
|
||||
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
|
||||
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
|
||||
Birthday *time.Time `json:"birthday"`
|
||||
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
|
||||
Stage string `gorm:"size:32" json:"stage"` // 刚到家 0-30 天 / 幼年期 / 成年期 / 老年期
|
||||
Age string `gorm:"size:32" json:"age"`
|
||||
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
|
||||
Breed string `gorm:"size:64" json:"breed"` // 品种
|
||||
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
|
||||
Goals datatypes.JSON `json:"goals"` // onboarding 目标多选
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// 计划类型
|
||||
const (
|
||||
PlanThirtyDay = "thirty_day"
|
||||
PlanAI = "ai"
|
||||
)
|
||||
|
||||
// Plan 养宠计划(30 天路线图 / AI 计划)
|
||||
type Plan struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Kind string `gorm:"size:16;index" json:"kind"` // thirty_day / ai
|
||||
Stage string `gorm:"size:32" json:"stage"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
UserInput string `gorm:"size:512" json:"user_input"`
|
||||
Extracted datatypes.JSON `json:"extracted"` // AI 提取信息
|
||||
CompletionPct int `json:"completion_pct"`
|
||||
Status string `gorm:"size:16;default:active" json:"status"` // active / completed / archived
|
||||
Tasks []PlanTask `gorm:"foreignKey:PlanID" json:"tasks"`
|
||||
}
|
||||
|
||||
// PlanTask 计划明细项
|
||||
type PlanTask struct {
|
||||
Base
|
||||
PlanID uint `gorm:"index" json:"plan_id"`
|
||||
Day int `json:"day"`
|
||||
DayLabel string `gorm:"size:32" json:"day_label"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
SheetType string `gorm:"size:32" json:"sheet_type"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "gorm.io/datatypes"
|
||||
|
||||
// 帖子状态
|
||||
const (
|
||||
PostPublished = "published"
|
||||
PostHidden = "hidden"
|
||||
PostDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Post 社区帖子
|
||||
type Post struct {
|
||||
Base
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
PetID *uint `json:"pet_id"`
|
||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
||||
AuthorEmoji string `gorm:"size:16" json:"author_emoji"`
|
||||
Identity string `gorm:"size:16" json:"identity"` // petName / anonymous / official
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Tags datatypes.JSON `json:"tags"`
|
||||
Images datatypes.JSON `json:"images"`
|
||||
LikeCount int `json:"like_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
// PostLike 帖子点赞,(post_id, user_id) 唯一
|
||||
type PostLike struct {
|
||||
Base
|
||||
PostID uint `gorm:"uniqueIndex:idx_post_user" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_post_user" json:"user_id"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Pro 会员状态
|
||||
const (
|
||||
ProActive = "active"
|
||||
ProExpired = "expired"
|
||||
ProNone = "none"
|
||||
)
|
||||
|
||||
// ProMembership 会员,1:1 于 User
|
||||
type ProMembership struct {
|
||||
Base
|
||||
UserID uint `gorm:"uniqueIndex" json:"user_id"`
|
||||
Status string `gorm:"size:16;default:none" json:"status"`
|
||||
PlanType string `gorm:"size:16" json:"plan_type"` // yearly / monthly
|
||||
Price float64 `gorm:"type:decimal(10,2)" json:"price"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 提醒类型
|
||||
const (
|
||||
ReminderVaccine = "vaccine"
|
||||
ReminderDeworm = "deworm"
|
||||
ReminderWeight = "weight"
|
||||
ReminderMonthlyReport = "monthlyReport"
|
||||
)
|
||||
|
||||
// Reminder 提醒
|
||||
type Reminder struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Type string `gorm:"size:24;index" json:"type"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
NextDueDate *time.Time `json:"next_due_date"`
|
||||
Frequency string `gorm:"size:64" json:"frequency"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
// User 小程序用户(铲屎官)
|
||||
type User struct {
|
||||
Base
|
||||
OpenID string `gorm:"size:64;index" json:"openid"`
|
||||
Nickname string `gorm:"size:64" json:"nickname"`
|
||||
Avatar string `gorm:"size:512" json:"avatar"`
|
||||
Phone string `gorm:"size:32" json:"phone"`
|
||||
Onboarded bool `json:"onboarded"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/admin"
|
||||
"github.com/sundynix/pets-be/internal/handler"
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// New 组装 gin 引擎与所有路由
|
||||
func New(h *handler.Handler, jm *appjwt.Manager, mode string) *gin.Engine {
|
||||
gin.SetMode(mode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||
|
||||
// 健康检查
|
||||
r.GET("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) })
|
||||
// 根路径跳转后台
|
||||
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
|
||||
|
||||
api := r.Group("/api")
|
||||
registerAuth(api, h)
|
||||
registerUserAPI(api, h, jm)
|
||||
registerAdminAPI(api, h, jm)
|
||||
|
||||
// 内嵌后台 SPA
|
||||
admin.Register(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func registerAuth(api *gin.RouterGroup, h *handler.Handler) {
|
||||
auth := api.Group("/auth")
|
||||
auth.POST("/login", h.Login)
|
||||
auth.POST("/wechat", h.WechatLogin)
|
||||
}
|
||||
|
||||
// 小程序用户接口(需 user JWT)
|
||||
func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
|
||||
g := api.Group("")
|
||||
g.Use(middleware.AuthUser(jm))
|
||||
|
||||
g.GET("/user/profile", h.Profile)
|
||||
g.PUT("/user/profile", h.UpdateProfile)
|
||||
g.GET("/user/summary", h.UserSummary)
|
||||
|
||||
g.GET("/pets", h.ListPets)
|
||||
g.POST("/pets", h.CreatePet)
|
||||
g.POST("/onboarding", h.Onboarding)
|
||||
g.GET("/pets/:id", h.GetPet)
|
||||
g.PUT("/pets/:id", h.UpdatePet)
|
||||
g.DELETE("/pets/:id", h.DeletePet)
|
||||
|
||||
g.GET("/pets/:id/records", h.ListRecords)
|
||||
g.POST("/pets/:id/records", h.CreateRecord)
|
||||
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
|
||||
g.DELETE("/records/:id", h.DeleteRecord)
|
||||
|
||||
g.GET("/pets/:id/tasks", h.ListTasks)
|
||||
g.POST("/pets/:id/tasks/complete-all", h.CompleteAllTasks)
|
||||
g.POST("/tasks/:id/toggle", h.ToggleTask)
|
||||
|
||||
g.GET("/pets/:id/home-summary", h.HomeSummary)
|
||||
|
||||
g.GET("/pets/:id/plan", h.GetPlan)
|
||||
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
|
||||
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
|
||||
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
|
||||
g.POST("/plan-tasks/:id/toggle", h.TogglePlanTask)
|
||||
|
||||
g.GET("/pets/:id/reminders", h.ListReminders)
|
||||
g.POST("/pets/:id/reminders", h.CreateReminder)
|
||||
g.PUT("/reminders/:id", h.UpdateReminder)
|
||||
g.DELETE("/reminders/:id", h.DeleteReminder)
|
||||
|
||||
g.GET("/pets/:id/report/weekly", h.WeeklyReport)
|
||||
g.GET("/pets/:id/bill", h.Bill)
|
||||
g.GET("/pets/:id/health-summary", h.HealthSummary)
|
||||
g.GET("/pets/:id/poster", h.Poster)
|
||||
|
||||
g.GET("/posts", h.ListPosts)
|
||||
g.POST("/posts", h.CreatePost)
|
||||
g.GET("/posts/:id", h.GetPost)
|
||||
g.POST("/posts/:id/like", h.LikePost)
|
||||
g.DELETE("/posts/:id/like", h.UnlikePost)
|
||||
g.GET("/posts/:id/comments", h.ListComments)
|
||||
g.POST("/posts/:id/comments", h.CreateComment)
|
||||
|
||||
g.GET("/articles", h.ListArticles)
|
||||
g.GET("/articles/:id", h.GetArticle)
|
||||
|
||||
g.GET("/pro", h.GetPro)
|
||||
g.POST("/pro/activate", h.ActivatePro)
|
||||
|
||||
g.POST("/ai/chat", h.AIChat)
|
||||
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
||||
g.POST("/upload", h.Upload)
|
||||
}
|
||||
|
||||
// 后台接口(登录开放,其余需 admin JWT)
|
||||
func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
|
||||
api.POST("/admin/login", h.AdminLogin)
|
||||
|
||||
g := api.Group("/admin")
|
||||
g.Use(middleware.AuthAdmin(jm))
|
||||
|
||||
g.GET("/me", h.AdminMe)
|
||||
g.GET("/stats", h.AdminStats)
|
||||
|
||||
g.GET("/users", h.AdminListUsers)
|
||||
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
|
||||
|
||||
g.GET("/pets", h.AdminListPets)
|
||||
|
||||
g.GET("/posts", h.AdminListPosts)
|
||||
g.PUT("/posts/:id/status", h.AdminSetPostStatus)
|
||||
|
||||
g.GET("/comments", h.AdminListComments)
|
||||
g.DELETE("/comments/:id", h.AdminDeleteComment)
|
||||
|
||||
g.GET("/articles", h.AdminListArticles)
|
||||
g.POST("/articles", h.AdminSaveArticle)
|
||||
g.DELETE("/articles/:id", h.AdminDeleteArticle)
|
||||
|
||||
g.GET("/memberships", h.AdminListPro)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// AdminLogin 校验管理员账号密码
|
||||
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
|
||||
var admin model.Admin
|
||||
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if admin.Disabled {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// GetAdmin 取管理员
|
||||
func (s *Service) GetAdmin(id uint) (*model.Admin, error) {
|
||||
var admin model.Admin
|
||||
if err := s.db.First(&admin, id).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// AdminStats 后台统计
|
||||
type AdminStats struct {
|
||||
Users int64 `json:"users"`
|
||||
Pets int64 `json:"pets"`
|
||||
Posts int64 `json:"posts"`
|
||||
Records int64 `json:"records"`
|
||||
}
|
||||
|
||||
// Stats Dashboard 计数
|
||||
func (s *Service) Stats() (*AdminStats, error) {
|
||||
var st AdminStats
|
||||
s.db.Model(&model.User{}).Count(&st.Users)
|
||||
s.db.Model(&model.Pet{}).Count(&st.Pets)
|
||||
s.db.Model(&model.Post{}).Count(&st.Posts)
|
||||
s.db.Model(&model.HealthRecord{}).Count(&st.Records)
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// ListUsers 用户分页(keyword 匹配昵称)
|
||||
func (s *Service) ListUsers(keyword string, offset, limit int) ([]model.User, int64, error) {
|
||||
q := s.db.Model(&model.User{})
|
||||
if keyword != "" {
|
||||
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var users []model.User
|
||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
|
||||
// SetUserDisabled 启用/禁用用户
|
||||
func (s *Service) SetUserDisabled(id uint, disabled bool) error {
|
||||
return s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error
|
||||
}
|
||||
|
||||
// ListPetsAdmin 宠物分页
|
||||
func (s *Service) ListPetsAdmin(offset, limit int) ([]model.Pet, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.Pet{}).Count(&total)
|
||||
var pets []model.Pet
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&pets).Error
|
||||
return pets, total, err
|
||||
}
|
||||
|
||||
// ListPostsAdmin 帖子分页(status 可选过滤)
|
||||
func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post, int64, error) {
|
||||
q := s.db.Model(&model.Post{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var posts []model.Post
|
||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
|
||||
return posts, total, err
|
||||
}
|
||||
|
||||
// SetPostStatus 审核帖子(published/hidden/deleted)
|
||||
func (s *Service) SetPostStatus(id uint, status string) error {
|
||||
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCommentsAdmin 评论分页
|
||||
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.Comment{}).Count(&total)
|
||||
var comments []model.Comment
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
|
||||
return comments, total, err
|
||||
}
|
||||
|
||||
// DeleteCommentAdmin 删除评论
|
||||
func (s *Service) DeleteCommentAdmin(id uint) error {
|
||||
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
|
||||
}
|
||||
|
||||
// ListArticlesAdmin 文章分页(含未发布)
|
||||
func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.Article{}).Count(&total)
|
||||
var articles []model.Article
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&articles).Error
|
||||
return articles, total, err
|
||||
}
|
||||
|
||||
// SaveArticle 新增或更新文章(ID 为 0 则新增)
|
||||
func (s *Service) SaveArticle(a *model.Article) error {
|
||||
if a.ID == 0 {
|
||||
return s.db.Create(a).Error
|
||||
}
|
||||
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
|
||||
"icon": a.Icon,
|
||||
"title": a.Title,
|
||||
"description": a.Description,
|
||||
"content": a.Content,
|
||||
"category": a.Category,
|
||||
"related_sheet_type": a.RelatedSheetType,
|
||||
"published": a.Published,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// DeleteArticle 删除文章
|
||||
func (s *Service) DeleteArticle(id uint) error {
|
||||
res := s.db.Delete(&model.Article{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProAdmin 会员分页
|
||||
func (s *Service) ListProAdmin(offset, limit int) ([]model.ProMembership, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.ProMembership{}).Count(&total)
|
||||
var list []model.ProMembership
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
|
||||
func (s *Service) AIChat(userID uint, petID *uint, session, text string) (string, error) {
|
||||
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
|
||||
if s.ai != nil && s.ai.Enabled() {
|
||||
if r, err := s.llmChat(petID, text); err == nil && r != "" {
|
||||
reply = r
|
||||
}
|
||||
}
|
||||
|
||||
msgs := []model.AIMessage{
|
||||
{UserID: userID, PetID: petID, Session: session, Role: "user", Text: text},
|
||||
{UserID: userID, PetID: petID, Session: session, Role: "ai", Text: reply},
|
||||
}
|
||||
if err := s.db.Create(&msgs).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// Upload 代理到对象存储
|
||||
func (s *Service) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
|
||||
return s.storage.Upload(objectName, reader, size, contentType)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// 养宠助手安全护栏:不诊断、只做观察与就医前信息整理、始终给就医提示
|
||||
const aiSafetyPrompt = `你是「毛孩子计划」的养宠助手,服务新手猫狗主人。要求:
|
||||
1. 只提供日常照护建议、观察要点和就医前信息整理,绝不做医学诊断或开具体药物剂量。
|
||||
2. 任何异常都要说明"何时需要尽快就医"(如持续超过24小时、便血、频繁呕吐、精神明显变差等)。
|
||||
3. 基于用户提供的宠物档案和记录作答,不要泛泛而谈。
|
||||
4. 语气亲切、简洁,用中文,避免长篇大论。`
|
||||
|
||||
// petBrief 组装宠物档案 + 近期记录的上下文文本
|
||||
func (s *Service) petBrief(petID uint) string {
|
||||
var pet model.Pet
|
||||
if err := s.db.First(&pet, petID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
var records []model.HealthRecord
|
||||
s.db.Where("pet_id = ?", petID).Order("occurred_at desc").Limit(8).Find(&records)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "宠物档案:%s(%s,%s,%s,当前体重%s)。\n",
|
||||
pet.Name, pet.Type, pet.Gender, pet.Stage, pet.Weight)
|
||||
if len(records) > 0 {
|
||||
b.WriteString("近期记录:")
|
||||
items := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
items = append(items, r.Title)
|
||||
}
|
||||
b.WriteString(strings.Join(items, ";"))
|
||||
b.WriteString("。")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// llmChat 真实模型聊天回复
|
||||
func (s *Service) llmChat(petID *uint, text string) (string, error) {
|
||||
system := aiSafetyPrompt
|
||||
if petID != nil {
|
||||
if brief := s.petBrief(*petID); brief != "" {
|
||||
system += "\n\n" + brief
|
||||
}
|
||||
}
|
||||
return s.ai.Complete(system, []ai.Message{{Role: "user", Content: text}}, ai.Options{Temperature: -1})
|
||||
}
|
||||
|
||||
// SymptomInput 异常观察入参
|
||||
type SymptomInput struct {
|
||||
Symptoms []string
|
||||
Duration string
|
||||
Spirit string
|
||||
}
|
||||
|
||||
// SymptomResult 风险评估结果(对应前端 risk 弹层)
|
||||
type SymptomResult struct {
|
||||
RiskLevel string `json:"risk_level"` // 低/中/高
|
||||
Causes string `json:"causes"`
|
||||
Suggestion string `json:"suggestion"`
|
||||
SeekCare string `json:"seek_care"`
|
||||
}
|
||||
|
||||
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
|
||||
func (s *Service) AssessSymptom(userID, petID uint, in SymptomInput) (*SymptomResult, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 规则化兜底
|
||||
fallback := &SymptomResult{
|
||||
RiskLevel: "中",
|
||||
Causes: "可能与换粮、应激或消化不适有关。这不是诊断结论。",
|
||||
Suggestion: "继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。",
|
||||
SeekCare: "如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。",
|
||||
}
|
||||
if s.ai == nil || !s.ai.Enabled() {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(petID) +
|
||||
`\n请根据以下异常信息评估,严格返回 JSON:{"risk_level":"低|中|高","causes":"可能原因(非诊断)","suggestion":"观察与照护建议","seek_care":"何时需要就医"}`
|
||||
user := fmt.Sprintf("异常表现:%s;持续时间:%s;精神状态:%s。",
|
||||
strings.Join(in.Symptoms, "、"), in.Duration, in.Spirit)
|
||||
|
||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
||||
if err != nil {
|
||||
return fallback, nil // 模型故障不阻断,回退
|
||||
}
|
||||
var res SymptomResult
|
||||
if err := json.Unmarshal([]byte(extractJSON(out)), &res); err != nil || res.RiskLevel == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// aiPlanExtract 用模型提取计划信息 + 生成任务;失败返回 ok=false 交由规则化处理
|
||||
type aiPlanExtracted struct {
|
||||
Stage string `json:"stage"`
|
||||
Risk string `json:"risk"`
|
||||
Priority string `json:"priority"`
|
||||
Reminder string `json:"reminder"`
|
||||
Tasks []struct {
|
||||
DayLabel string `json:"day_label"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
} `json:"tasks"`
|
||||
}
|
||||
|
||||
func (s *Service) aiPlanExtract(pet *model.Pet, input string) (*aiPlanExtracted, bool) {
|
||||
if s.ai == nil || !s.ai.Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
|
||||
`\n请把用户描述提炼为可执行的养宠计划,严格返回 JSON:` +
|
||||
`{"stage":"阶段","risk":"近期风险","priority":"观察重点","reminder":"重点提醒",` +
|
||||
`"tasks":[{"day_label":"如 Day 1-2","title":"任务标题","description":"要点"}]},tasks 3-5 条。`
|
||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: input}}, ai.Options{JSON: true, Temperature: -1})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var ex aiPlanExtracted
|
||||
if err := json.Unmarshal([]byte(extractJSON(out)), &ex); err != nil || len(ex.Tasks) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return &ex, true
|
||||
}
|
||||
|
||||
// extractJSON 容错:从可能含前后缀的文本中截取第一个 { 到最后一个 }
|
||||
func extractJSON(s string) string {
|
||||
i := strings.IndexByte(s, '{')
|
||||
j := strings.LastIndexByte(s, '}')
|
||||
if i >= 0 && j > i {
|
||||
return s[i : j+1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ListArticles 已发布文章
|
||||
func (s *Service) ListArticles() ([]model.Article, error) {
|
||||
var articles []model.Article
|
||||
err := s.db.Where("published = ?", true).Order("id asc").Find(&articles).Error
|
||||
return articles, err
|
||||
}
|
||||
|
||||
// GetArticle 文章详情
|
||||
func (s *Service) GetArticle(id uint) (*model.Article, error) {
|
||||
var a model.Article
|
||||
if err := s.db.First(&a, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// MockLogin 开发态登录:按昵称找回或新建用户
|
||||
func (s *Service) MockLogin(nickname string) (*model.User, error) {
|
||||
if nickname == "" {
|
||||
nickname = "毛孩子用户"
|
||||
}
|
||||
var u model.User
|
||||
err := s.db.Where("nickname = ?", nickname).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
u = model.User{Nickname: nickname}
|
||||
if e := s.db.Create(&u).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// code2SessionResp 微信 jscode2session 返回
|
||||
type code2SessionResp struct {
|
||||
OpenID string `json:"openid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// code2Session 调用微信换取 openid
|
||||
func (s *Service) code2Session(code string) (*code2SessionResp, error) {
|
||||
if s.cfg.WeChat.AppID == "" || s.cfg.WeChat.AppSecret == "" {
|
||||
return nil, errors.New("微信登录未配置 app_id / app_secret")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("appid", s.cfg.WeChat.AppID)
|
||||
q.Set("secret", s.cfg.WeChat.AppSecret)
|
||||
q.Set("js_code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + q.Encode()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out code2SessionResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("微信登录失败(%d): %s", out.ErrCode, out.ErrMsg)
|
||||
}
|
||||
if out.OpenID == "" {
|
||||
return nil, errors.New("微信未返回 openid")
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// WechatLogin 微信小程序登录:code → openid → 找回/新建用户
|
||||
func (s *Service) WechatLogin(code string) (*model.User, error) {
|
||||
sess, err := s.code2Session(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var u model.User
|
||||
err = s.db.Where("open_id = ?", sess.OpenID).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
u = model.User{OpenID: sess.OpenID, Nickname: "微信用户"}
|
||||
if e := s.db.Create(&u).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUser 取用户
|
||||
func (s *Service) GetUser(userID uint) (*model.User, error) {
|
||||
var u model.User
|
||||
if err := s.db.First(&u, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户资料(昵称/头像/手机号)
|
||||
func (s *Service) UpdateUser(userID uint, fields map[string]any) (*model.User, error) {
|
||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetUser(userID)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
|
||||
func tabTag(tab string) string {
|
||||
switch tab {
|
||||
case "新手求助":
|
||||
return "求助"
|
||||
case "晒宠":
|
||||
return "晒宠"
|
||||
case "经验":
|
||||
return "经验"
|
||||
default: // 推荐 / 关注
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ListPosts 帖子分页列表
|
||||
func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64, error) {
|
||||
q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished)
|
||||
if tag := tabTag(tab); tag != "" {
|
||||
q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var posts []model.Post
|
||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// GetPost 帖子详情
|
||||
func (s *Service) GetPost(postID uint) (*model.Post, error) {
|
||||
var p model.Post
|
||||
if err := s.db.First(&p, postID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// PostInput 发帖入参
|
||||
type PostInput struct {
|
||||
PetID *uint
|
||||
Identity string
|
||||
Content string
|
||||
Tags datatypes.JSON
|
||||
Images datatypes.JSON
|
||||
}
|
||||
|
||||
// CreatePost 发帖
|
||||
func (s *Service) CreatePost(userID uint, in PostInput) (*model.Post, error) {
|
||||
authorName := "匿名宠友"
|
||||
authorEmoji := "🐾"
|
||||
switch in.Identity {
|
||||
case "official":
|
||||
authorName = "毛孩子计划官方"
|
||||
case "petName":
|
||||
if in.PetID != nil {
|
||||
var pet model.Pet
|
||||
if err := s.db.First(&pet, *in.PetID).Error; err == nil {
|
||||
authorName = pet.Name + "的铲屎官"
|
||||
authorEmoji = pet.Emoji
|
||||
}
|
||||
}
|
||||
}
|
||||
p := model.Post{
|
||||
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
|
||||
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
|
||||
Status: model.PostPublished,
|
||||
}
|
||||
if err := s.db.Create(&p).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// LikePost 点赞(幂等:已赞则不重复计数)
|
||||
func (s *Service) LikePost(userID, postID uint) (int, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
like := model.PostLike{PostID: postID, UserID: userID}
|
||||
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).FirstOrCreate(&like)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 1 { // 新建才计数
|
||||
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
||||
UpdateColumn("like_count", gorm.Expr("like_count + 1")).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.postLikeCount(postID)
|
||||
}
|
||||
|
||||
// UnlikePost 取消点赞
|
||||
func (s *Service) UnlikePost(userID, postID uint) (int, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&model.PostLike{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 1 {
|
||||
return tx.Model(&model.Post{}).Where("id = ? AND like_count > 0", postID).
|
||||
UpdateColumn("like_count", gorm.Expr("like_count - 1")).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.postLikeCount(postID)
|
||||
}
|
||||
|
||||
func (s *Service) postLikeCount(postID uint) (int, error) {
|
||||
var p model.Post
|
||||
if err := s.db.Select("like_count").First(&p, postID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.LikeCount, nil
|
||||
}
|
||||
|
||||
// ListComments 评论分页
|
||||
func (s *Service) ListComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
|
||||
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var comments []model.Comment
|
||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return comments, total, nil
|
||||
}
|
||||
|
||||
// CreateComment 评论
|
||||
func (s *Service) CreateComment(userID, postID uint, content string) (*model.Comment, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var user model.User
|
||||
name := "宠友"
|
||||
if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" {
|
||||
name = user.Nickname
|
||||
}
|
||||
comment := model.Comment{PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: "published"}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
||||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// HomeInsight 首页洞察条一项:Bold 为大号加粗值,Text 为说明
|
||||
type HomeInsight struct {
|
||||
Bold string `json:"bold"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// WeekDay 首页日期条一格
|
||||
type WeekDay struct {
|
||||
Weekday string `json:"weekday"` // 一二三四五六日
|
||||
Day int `json:"day"`
|
||||
Active bool `json:"active"` // 是否今天
|
||||
HasDot bool `json:"has_dot"` // 当天有任务/记录/提醒
|
||||
}
|
||||
|
||||
// HomeSummary 首页所需的全部动态数据
|
||||
type HomeSummary struct {
|
||||
Greeting string `json:"greeting"`
|
||||
Insights []HomeInsight `json:"insights"`
|
||||
Week []WeekDay `json:"week"`
|
||||
Advice string `json:"advice"`
|
||||
HealthPct int `json:"health_pct"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
}
|
||||
|
||||
var weekdayCN = []string{"日", "一", "二", "三", "四", "五", "六"}
|
||||
|
||||
// GetHomeSummary 计算首页汇总
|
||||
func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
res := &HomeSummary{
|
||||
Greeting: greeting(now, s.userNickname(userID)),
|
||||
HealthStatus: pet.HealthStatus,
|
||||
Insights: s.homeInsights(petID, now),
|
||||
Week: s.homeWeek(petID, now),
|
||||
Advice: s.dailyAdvice(pet),
|
||||
HealthPct: s.todayCompletionPct(petID, now),
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) userNickname(userID uint) string {
|
||||
var u model.User
|
||||
if err := s.db.Select("nickname").First(&u, userID).Error; err == nil && u.Nickname != "" {
|
||||
return u.Nickname
|
||||
}
|
||||
return "铲屎官"
|
||||
}
|
||||
|
||||
func greeting(t time.Time, name string) string {
|
||||
h := t.Hour()
|
||||
switch {
|
||||
case h < 11:
|
||||
return "早上好," + name
|
||||
case h < 14:
|
||||
return "中午好," + name
|
||||
case h < 18:
|
||||
return "下午好," + name
|
||||
default:
|
||||
return "晚上好," + name
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
|
||||
insights := make([]HomeInsight, 0, 3)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
// 1) 最近一条未到期的提醒
|
||||
var rem model.Reminder
|
||||
if err := s.db.Where("pet_id = ? AND next_due_date >= ?", petID, today).
|
||||
Order("next_due_date asc").First(&rem).Error; err == nil && rem.NextDueDate != nil {
|
||||
days := int(math.Ceil(rem.NextDueDate.Sub(today).Hours() / 24))
|
||||
bold := fmt.Sprintf("%d天后", days)
|
||||
if days <= 0 {
|
||||
bold = "今天"
|
||||
}
|
||||
insights = append(insights, HomeInsight{Bold: bold, Text: rem.Title})
|
||||
}
|
||||
|
||||
// 2) 本周体重变化
|
||||
weekAgo := today.AddDate(0, 0, -7)
|
||||
var latest, earliest model.HealthRecord
|
||||
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at desc").First(&latest).Error == nil {
|
||||
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at asc").First(&earliest).Error == nil {
|
||||
d := latest.NumValue - earliest.NumValue
|
||||
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%+.1fkg", d), Text: "本周体重"})
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 连续记录天数
|
||||
if n := s.streakDays(petID, now); n > 0 {
|
||||
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%d天", n), Text: "连续记录"})
|
||||
}
|
||||
return insights
|
||||
}
|
||||
|
||||
// streakDays 从今天往前,连续有健康记录的天数
|
||||
func (s *Service) streakDays(petID uint, now time.Time) int {
|
||||
streak := 0
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
for i := 0; i < 60; i++ {
|
||||
start := day.AddDate(0, 0, -i)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var cnt int64
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
break
|
||||
}
|
||||
streak++
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
|
||||
// 本周一为起点
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
offset := (int(today.Weekday()) + 6) % 7 // 周一=0
|
||||
monday := today.AddDate(0, 0, -offset)
|
||||
|
||||
dotSet := s.datesWithActivity(petID, monday, monday.AddDate(0, 0, 7))
|
||||
|
||||
week := make([]WeekDay, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
d := monday.AddDate(0, 0, i)
|
||||
week[i] = WeekDay{
|
||||
Weekday: weekdayCN[int(d.Weekday())],
|
||||
Day: d.Day(),
|
||||
Active: d.Equal(today),
|
||||
HasDot: dotSet[d.Format("2006-01-02")],
|
||||
}
|
||||
}
|
||||
return week
|
||||
}
|
||||
|
||||
// datesWithActivity 区间内有任务/记录/提醒的日期集合
|
||||
func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string]bool {
|
||||
set := map[string]bool{}
|
||||
var ts []time.Time
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Pluck("task_date", &ts)
|
||||
for _, t := range ts {
|
||||
set[t.Format("2006-01-02")] = true
|
||||
}
|
||||
var rs []time.Time
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Pluck("occurred_at", &rs)
|
||||
for _, t := range rs {
|
||||
set[t.Format("2006-01-02")] = true
|
||||
}
|
||||
var reminders []model.Reminder
|
||||
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, start, end).Find(&reminders)
|
||||
for _, r := range reminders {
|
||||
if r.NextDueDate != nil {
|
||||
set[r.NextDueDate.Format("2006-01-02")] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (s *Service) todayCompletionPct(petID uint, now time.Time) int {
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var total, done int64
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Count(&total)
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ? AND done = ?", petID, start, end, true).Count(&done)
|
||||
return int(math.Round(float64(done) / float64(total) * 100))
|
||||
}
|
||||
|
||||
// dailyAdvice 今日建议:每宠每天缓存一条;启用模型则真调,否则规则化
|
||||
func (s *Service) dailyAdvice(pet *model.Pet) string {
|
||||
day := time.Now().Format("2006-01-02")
|
||||
var da model.DailyAdvice
|
||||
if err := s.db.Where("pet_id = ? AND day = ?", pet.ID, day).First(&da).Error; err == nil {
|
||||
return da.Text
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s处于%s,建议今天重点观察体重、饮食和排便。若出现连续呕吐、精神明显变差或便血,应尽快就医。", pet.Name, pet.Stage)
|
||||
if s.ai != nil && s.ai.Enabled() {
|
||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
|
||||
"\n请用2-3句话给出今天的养宠重点提醒,亲切简洁,直接给建议不要寒暄。"
|
||||
if r, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: "今天的养宠建议"}}, ai.Options{Temperature: -1}); err == nil && r != "" {
|
||||
text = r
|
||||
}
|
||||
}
|
||||
_ = s.db.Create(&model.DailyAdvice{PetID: pet.ID, Day: day, Text: text}).Error
|
||||
return text
|
||||
}
|
||||
|
||||
// UserSummary 我的页所需计数
|
||||
type UserSummary struct {
|
||||
Pets int64 `json:"pets"`
|
||||
Records int64 `json:"records"`
|
||||
Reminders int64 `json:"reminders"`
|
||||
ProStatus string `json:"pro_status"`
|
||||
}
|
||||
|
||||
// GetUserSummary 汇总当前用户名下计数
|
||||
func (s *Service) GetUserSummary(userID uint) (*UserSummary, error) {
|
||||
var sum UserSummary
|
||||
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
|
||||
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
|
||||
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
|
||||
|
||||
sum.ProStatus = model.ProNone
|
||||
var m model.ProMembership
|
||||
if err := s.db.Where("user_id = ?", userID).First(&m).Error; err == nil {
|
||||
sum.ProStatus = m.Status
|
||||
}
|
||||
return &sum, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// PetInput 建/改宠物入参
|
||||
type PetInput struct {
|
||||
Name string
|
||||
Emoji string
|
||||
Type string
|
||||
Gender string
|
||||
Birthday *time.Time
|
||||
Weight string
|
||||
Stage string
|
||||
Age string
|
||||
Color string
|
||||
Breed string
|
||||
Goals datatypes.JSON
|
||||
}
|
||||
|
||||
func normalizeWeight(w string) string {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(w, "kg") {
|
||||
return w
|
||||
}
|
||||
return w + "kg"
|
||||
}
|
||||
|
||||
// ListPets 用户的全部宠物
|
||||
func (s *Service) ListPets(userID uint) ([]model.Pet, error) {
|
||||
var pets []model.Pet
|
||||
err := s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets).Error
|
||||
return pets, err
|
||||
}
|
||||
|
||||
// GetPet 取单只宠物(校验归属)
|
||||
func (s *Service) GetPet(userID, petID uint) (*model.Pet, error) {
|
||||
return s.ownedPet(userID, petID)
|
||||
}
|
||||
|
||||
// CreatePet 新增宠物并生成默认数据
|
||||
func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
|
||||
pet := model.Pet{
|
||||
UserID: userID,
|
||||
Name: in.Name,
|
||||
Emoji: in.Emoji,
|
||||
Type: in.Type,
|
||||
Gender: in.Gender,
|
||||
Birthday: in.Birthday,
|
||||
Weight: normalizeWeight(in.Weight),
|
||||
Stage: in.Stage,
|
||||
Age: in.Age,
|
||||
Color: in.Color,
|
||||
Breed: in.Breed,
|
||||
HealthStatus: "正常",
|
||||
Goals: in.Goals,
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&pet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.seedPetDefaults(tx, &pet)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pet, nil
|
||||
}
|
||||
|
||||
// UpdatePet 更新宠物字段
|
||||
func (s *Service) UpdatePet(userID, petID uint, fields map[string]any) (*model.Pet, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w, ok := fields["weight"].(string); ok {
|
||||
fields["weight"] = normalizeWeight(w)
|
||||
}
|
||||
if err := s.db.Model(&model.Pet{}).Where("id = ?", petID).Updates(fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ownedPet(userID, petID)
|
||||
}
|
||||
|
||||
// DeletePet 删除宠物并级联清除其记录/任务/计划/提醒/每日建议(不留孤儿数据)
|
||||
func (s *Service) DeletePet(userID, petID uint) error {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先删计划明细(按 plan_id),再删计划
|
||||
var planIDs []uint
|
||||
tx.Model(&model.Plan{}).Where("pet_id = ?", petID).Pluck("id", &planIDs)
|
||||
if len(planIDs) > 0 {
|
||||
if err := tx.Where("plan_id IN ?", planIDs).Delete(&model.PlanTask{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, m := range []any{
|
||||
&model.HealthRecord{}, &model.DailyTask{}, &model.Reminder{},
|
||||
&model.Plan{}, &model.DailyAdvice{},
|
||||
} {
|
||||
if err := tx.Where("pet_id = ?", petID).Delete(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Where("id = ? AND user_id = ?", petID, userID).Delete(&model.Pet{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Onboarding 建首宠 + 标记用户已引导
|
||||
func (s *Service) Onboarding(userID uint, in PetInput) (*model.Pet, error) {
|
||||
pet, err := s.CreatePet(userID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Update("onboarded", true).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pet, nil
|
||||
}
|
||||
|
||||
// seedPetDefaults 为新宠生成默认今日任务、提醒、30 天计划
|
||||
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||
today := time.Now()
|
||||
tasks := []model.DailyTask{
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "记录一次体重", Description: "幼年期建议每周至少记录 2 次", Priority: "重要"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "观察饮水和排便", Description: "换粮、应激都可能影响排便状态", SheetType: "poop"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "检查疫苗预约", Description: "第 2 针疫苗还有 5 天", SheetType: "vaccine"},
|
||||
}
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vaccineDue := today.AddDate(0, 0, 18)
|
||||
dewormDue := today.AddDate(0, 0, 12)
|
||||
reminders := []model.Reminder{
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderVaccine, Title: "第 2 针疫苗", NextDueDate: &vaccineDue},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderDeworm, Title: "体内外驱虫", NextDueDate: &dewormDue},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderWeight, Title: "体重记录", Frequency: "每周二、周五"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderMonthlyReport, Title: "月度报告", Frequency: "每月 1 日"},
|
||||
}
|
||||
if err := tx.Create(&reminders).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
start := today
|
||||
end := today.AddDate(0, 0, 30)
|
||||
plan := model.Plan{
|
||||
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
|
||||
StartDate: &start, EndDate: &end, CompletionPct: 40, Status: "active",
|
||||
Tasks: []model.PlanTask{
|
||||
{Day: 0, DayLabel: "今天", Title: "观察排便状态", Description: "记录颜色、形态、次数,发现软便可连续观察。", SheetType: "taskDetail"},
|
||||
{Day: 1, DayLabel: "明天", Title: "检查疫苗预约", Description: "距离下一针还有 5 天,提前确认医院和时间。", SheetType: "vaccine"},
|
||||
{Day: 7, DayLabel: "第 7 天", Title: "体重趋势检查", Description: "幼年期每周称重,观察是否稳定增长。", SheetType: "weight"},
|
||||
{Day: 14, DayLabel: "第 14 天", Title: "复盘饮食与便便", Description: "如果近期换粮,建议把换粮过程和异常记录合并查看。", SheetType: "taskDetail"},
|
||||
},
|
||||
}
|
||||
return tx.Create(&plan).Error
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// TogglePlanTask 切换计划明细完成状态(校验归属)
|
||||
func (s *Service) TogglePlanTask(userID, taskID uint) error {
|
||||
var pt model.PlanTask
|
||||
if err := s.db.First(&pt, taskID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
var plan model.Plan
|
||||
if err := s.db.Where("id = ? AND user_id = ?", pt.PlanID, userID).First(&plan).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", !pt.Done).Error
|
||||
}
|
||||
|
||||
// GetPlan 取宠物的 30 天计划(含明细)
|
||||
func (s *Service) GetPlan(userID, petID uint) (*model.Plan, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var plan model.Plan
|
||||
err := s.db.Preload("Tasks").
|
||||
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
|
||||
Order("id desc").First(&plan).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 真实完成度:已完成明细 / 总明细
|
||||
if n := len(plan.Tasks); n > 0 {
|
||||
done := 0
|
||||
for _, t := range plan.Tasks {
|
||||
if t.Done {
|
||||
done++
|
||||
}
|
||||
}
|
||||
plan.CompletionPct = int(math.Round(float64(done) / float64(n) * 100))
|
||||
} else {
|
||||
plan.CompletionPct = 0
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// CalendarResult 日历视图
|
||||
type CalendarResult struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
TaskedDays []int `json:"tasked_days"`
|
||||
Today int `json:"today"`
|
||||
}
|
||||
|
||||
// Calendar 某月有任务/提醒的日期
|
||||
func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loc := time.Now().Location()
|
||||
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
|
||||
daySet := map[int]struct{}{}
|
||||
|
||||
var taskDates []time.Time
|
||||
s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
|
||||
Pluck("task_date", &taskDates)
|
||||
for _, t := range taskDates {
|
||||
daySet[t.Day()] = struct{}{}
|
||||
}
|
||||
|
||||
var reminders []model.Reminder
|
||||
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, start, end).Find(&reminders)
|
||||
for _, r := range reminders {
|
||||
if r.NextDueDate != nil {
|
||||
daySet[r.NextDueDate.Day()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
days := make([]int, 0, len(daySet))
|
||||
for d := range daySet {
|
||||
days = append(days, d)
|
||||
}
|
||||
|
||||
today := 0
|
||||
now := time.Now()
|
||||
if now.Year() == year && int(now.Month()) == month {
|
||||
today = now.Day()
|
||||
}
|
||||
return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil
|
||||
}
|
||||
|
||||
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
|
||||
func (s *Service) CreateAIPlan(userID, petID uint, input string) (*model.Plan, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 默认(规则化)提取与任务
|
||||
var extracted any = extractAIInfo(input, pet.Stage)
|
||||
tasks := []model.PlanTask{
|
||||
{DayLabel: "Day 1-2", Title: "观察食欲和便便", Description: "每日记录饮食、便便、精神状态。"},
|
||||
{DayLabel: "Day 3-5", Title: "记录换粮比例", Description: "逐步提高新粮比例,留意软便。"},
|
||||
{DayLabel: "Day 6-7", Title: "生成复盘建议", Description: "汇总一周状态,输出复盘。"},
|
||||
}
|
||||
|
||||
// 启用模型则用真实提取覆盖
|
||||
if ex, ok := s.aiPlanExtract(pet, input); ok {
|
||||
extracted = map[string]string{
|
||||
"stage": ex.Stage, "risk": ex.Risk, "priority": ex.Priority, "reminder": ex.Reminder,
|
||||
}
|
||||
tasks = tasks[:0]
|
||||
for _, t := range ex.Tasks {
|
||||
tasks = append(tasks, model.PlanTask{DayLabel: t.DayLabel, Title: t.Title, Description: t.Description})
|
||||
}
|
||||
}
|
||||
|
||||
b, _ := jsonMarshalAny(extracted)
|
||||
plan := model.Plan{
|
||||
PetID: petID, UserID: userID, Kind: model.PlanAI, Stage: pet.Stage,
|
||||
UserInput: input, Extracted: datatypes.JSON(b), Status: "draft", Tasks: tasks,
|
||||
}
|
||||
if err := s.db.Create(&plan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// ApplyAIPlan 确认并应用 AI 计划
|
||||
func (s *Service) ApplyAIPlan(userID, planID uint) (*model.Plan, error) {
|
||||
var plan model.Plan
|
||||
if err := s.db.Where("id = ? AND user_id = ?", planID, userID).First(&plan).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
end := now.AddDate(0, 0, 7)
|
||||
if err := s.db.Model(&plan).Updates(map[string]any{
|
||||
"status": "active", "start_date": now, "end_date": end,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Preload("Tasks").First(&plan, plan.ID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// extractAIInfo 规则化提取(后续可替换为大模型)
|
||||
func extractAIInfo(input, stage string) map[string]string {
|
||||
risk := "暂无明显风险"
|
||||
priority := "日常观察"
|
||||
reminder := "常规提醒"
|
||||
if strings.Contains(input, "换粮") || strings.Contains(input, "软便") {
|
||||
risk = "换粮软便"
|
||||
priority = "排便观察"
|
||||
}
|
||||
if strings.Contains(input, "疫苗") {
|
||||
reminder = "第二针疫苗"
|
||||
}
|
||||
if stage == "" {
|
||||
stage = "幼年期"
|
||||
}
|
||||
return map[string]string{
|
||||
"stage": stage + " / 疫苗期",
|
||||
"risk": risk,
|
||||
"priority": priority,
|
||||
"reminder": reminder,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ProFeatures Pro 权益列表
|
||||
var ProFeatures = []string{
|
||||
"365 天养宠计划",
|
||||
"多宠物管理",
|
||||
"月度成长报告",
|
||||
"PDF 健康档案",
|
||||
"年度养宠账单",
|
||||
}
|
||||
|
||||
// ProInfo Pro 状态 + 权益
|
||||
type ProInfo struct {
|
||||
Status string `json:"status"`
|
||||
PlanType string `json:"plan_type"`
|
||||
Price float64 `json:"price"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
|
||||
// GetPro 取会员信息
|
||||
func (s *Service) GetPro(userID uint) (*ProInfo, error) {
|
||||
var m model.ProMembership
|
||||
err := s.db.Where("user_id = ?", userID).First(&m).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return &ProInfo{Status: model.ProNone, Features: ProFeatures}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProInfo{Status: m.Status, PlanType: m.PlanType, Price: m.Price, EndDate: m.EndDate, Features: ProFeatures}, nil
|
||||
}
|
||||
|
||||
// ActivatePro 开通年费会员
|
||||
func (s *Service) ActivatePro(userID uint) (*ProInfo, error) {
|
||||
now := time.Now()
|
||||
end := now.AddDate(1, 0, 0)
|
||||
var m model.ProMembership
|
||||
err := s.db.Where("user_id = ?", userID).First(&m).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
m = model.ProMembership{UserID: userID}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Status = model.ProActive
|
||||
m.PlanType = "yearly"
|
||||
m.Price = 29.9
|
||||
m.StartDate = &now
|
||||
m.EndDate = &end
|
||||
if err := s.db.Save(&m).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProInfo{Status: m.Status, PlanType: m.PlanType, Price: m.Price, EndDate: m.EndDate, Features: ProFeatures}, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// RecordInput 健康记录入参
|
||||
type RecordInput struct {
|
||||
Type string
|
||||
Icon string
|
||||
Title string
|
||||
Description string
|
||||
NumValue float64
|
||||
Category string
|
||||
ImageURL string
|
||||
Extra datatypes.JSON
|
||||
OccurredAt *time.Time
|
||||
}
|
||||
|
||||
// ListRecords 列出宠物的健康记录(可按 type 过滤)
|
||||
func (s *Service) ListRecords(userID, petID uint, recordType string) ([]model.HealthRecord, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := s.db.Where("pet_id = ?", petID)
|
||||
if recordType != "" {
|
||||
q = q.Where("type = ?", recordType)
|
||||
}
|
||||
var records []model.HealthRecord
|
||||
err := q.Order("occurred_at desc, id desc").Find(&records).Error
|
||||
return records, err
|
||||
}
|
||||
|
||||
// CreateRecord 新增健康记录;weight 类型同步更新宠物体重
|
||||
func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.HealthRecord, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
occurred := time.Now()
|
||||
if in.OccurredAt != nil {
|
||||
occurred = *in.OccurredAt
|
||||
}
|
||||
rec := model.HealthRecord{
|
||||
PetID: petID, UserID: userID, Type: in.Type, Icon: in.Icon,
|
||||
Title: in.Title, Description: in.Description, NumValue: in.NumValue,
|
||||
Category: in.Category, ImageURL: in.ImageURL, Extra: in.Extra, OccurredAt: occurred,
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&rec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Type == model.RecordWeight && in.NumValue > 0 {
|
||||
weight := fmt.Sprintf("%gkg", in.NumValue)
|
||||
if err := tx.Model(&model.Pet{}).Where("id = ?", pet.ID).Update("weight", weight).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// DeleteRecord 删除记录
|
||||
func (s *Service) DeleteRecord(userID, recordID uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", recordID, userID).Delete(&model.HealthRecord{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WeightPoint 体重趋势点
|
||||
type WeightPoint struct {
|
||||
Value float64 `json:"value"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
// WeightTrend 最近 N 次体重(升序)
|
||||
func (s *Service) WeightTrend(userID, petID uint, limit int) ([]WeightPoint, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 7
|
||||
}
|
||||
var records []model.HealthRecord
|
||||
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.RecordWeight).
|
||||
Order("occurred_at desc").Limit(limit).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points := make([]WeightPoint, 0, len(records))
|
||||
for i := len(records) - 1; i >= 0; i-- {
|
||||
points = append(points, WeightPoint{Value: records[i].NumValue, OccurredAt: records[i].OccurredAt})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ReminderInput 提醒入参
|
||||
type ReminderInput struct {
|
||||
Type string
|
||||
Title string
|
||||
NextDueDate *time.Time
|
||||
Frequency string
|
||||
}
|
||||
|
||||
// ListReminders 宠物的提醒列表
|
||||
func (s *Service) ListReminders(userID, petID uint) ([]model.Reminder, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reminders []model.Reminder
|
||||
err := s.db.Where("pet_id = ?", petID).Order("id asc").Find(&reminders).Error
|
||||
return reminders, err
|
||||
}
|
||||
|
||||
// CreateReminder 新增提醒
|
||||
func (s *Service) CreateReminder(userID, petID uint, in ReminderInput) (*model.Reminder, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := model.Reminder{
|
||||
PetID: petID, UserID: userID, Type: in.Type, Title: in.Title,
|
||||
NextDueDate: in.NextDueDate, Frequency: in.Frequency,
|
||||
}
|
||||
if err := s.db.Create(&r).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpdateReminder 更新提醒
|
||||
func (s *Service) UpdateReminder(userID, reminderID uint, fields map[string]any) (*model.Reminder, error) {
|
||||
var r model.Reminder
|
||||
if err := s.db.Where("id = ? AND user_id = ?", reminderID, userID).First(&r).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := s.db.Model(&r).Updates(fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// DeleteReminder 删除提醒
|
||||
func (s *Service) DeleteReminder(userID, reminderID uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", reminderID, userID).Delete(&model.Reminder{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// WeeklyReport 周报聚合
|
||||
type WeeklyReport struct {
|
||||
Summary string `json:"summary"`
|
||||
TasksCompleted int64 `json:"tasks_completed"`
|
||||
WeightGain float64 `json:"weight_gain"`
|
||||
HighRiskCount int64 `json:"high_risk_count"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
NextWeekFocus string `json:"next_week_focus"`
|
||||
}
|
||||
|
||||
// GetWeeklyReport 计算最近 7 天周报
|
||||
func (s *Service) GetWeeklyReport(userID, petID uint) (*WeeklyReport, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
weekAgo := time.Now().AddDate(0, 0, -7)
|
||||
|
||||
var tasksCompleted int64
|
||||
s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND done = ? AND updated_at >= ?", petID, true, weekAgo).
|
||||
Count(&tasksCompleted)
|
||||
|
||||
// 体重增长:最近 7 天最新 - 最早
|
||||
var latest, earliest model.HealthRecord
|
||||
gain := 0.0
|
||||
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at desc").First(&latest).Error; err == nil {
|
||||
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at asc").First(&earliest).Error; err == nil {
|
||||
gain = latest.NumValue - earliest.NumValue
|
||||
}
|
||||
}
|
||||
|
||||
var highRisk int64
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Where("pet_id = ? AND type = ? AND category = ? AND occurred_at >= ?", petID, model.RecordSymptom, "高", weekAgo).
|
||||
Count(&highRisk)
|
||||
|
||||
status := "稳定成长"
|
||||
if highRisk > 0 {
|
||||
status = "需要关注"
|
||||
}
|
||||
return &WeeklyReport{
|
||||
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg,无高风险异常记录。", pet.Name, tasksCompleted, gain),
|
||||
TasksCompleted: tasksCompleted,
|
||||
WeightGain: gain,
|
||||
HighRiskCount: highRisk,
|
||||
HealthStatus: status,
|
||||
NextWeekFocus: "第 2 针疫苗提醒、继续观察体重趋势、避免频繁更换食物。",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BillCategory 账单分类项
|
||||
type BillCategory struct {
|
||||
Category string `json:"category"`
|
||||
Amount float64 `json:"amount"`
|
||||
Percent int `json:"percent"`
|
||||
}
|
||||
|
||||
// Bill 账单聚合
|
||||
type Bill struct {
|
||||
Period string `json:"period"`
|
||||
Total float64 `json:"total"`
|
||||
MaxSingle float64 `json:"max_single"`
|
||||
Categories []BillCategory `json:"categories"`
|
||||
}
|
||||
|
||||
// GetBill 账单(period=month 取本月)
|
||||
func (s *Service) GetBill(userID, petID uint, period string) (*Bill, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
var start time.Time
|
||||
if period == "year" {
|
||||
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||
} else {
|
||||
period = "month"
|
||||
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Category string
|
||||
Amount float64
|
||||
}
|
||||
var rows []row
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Select("category, sum(num_value) as amount").
|
||||
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
|
||||
Group("category").Scan(&rows)
|
||||
|
||||
var total, maxSingle float64
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Select("coalesce(sum(num_value),0)").
|
||||
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
|
||||
Scan(&total)
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Select("coalesce(max(num_value),0)").
|
||||
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
|
||||
Scan(&maxSingle)
|
||||
|
||||
cats := make([]BillCategory, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
pct := 0
|
||||
if total > 0 {
|
||||
pct = int(r.Amount / total * 100)
|
||||
}
|
||||
cats = append(cats, BillCategory{Category: r.Category, Amount: r.Amount, Percent: pct})
|
||||
}
|
||||
return &Bill{Period: period, Total: total, MaxSingle: maxSingle, Categories: cats}, nil
|
||||
}
|
||||
|
||||
// HealthSummary 健康摘要
|
||||
type HealthSummary struct {
|
||||
VaccineProgress string `json:"vaccine_progress"`
|
||||
DewormStatus string `json:"deworm_status"`
|
||||
WeightTrend string `json:"weight_trend"`
|
||||
AnomalyCount int64 `json:"anomaly_count"`
|
||||
}
|
||||
|
||||
// GetHealthSummary 健康摘要聚合
|
||||
func (s *Service) GetHealthSummary(userID, petID uint) (*HealthSummary, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var vaccineDone int64
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineDone)
|
||||
|
||||
dewormStatus := "暂无计划"
|
||||
var dewormReminder model.Reminder
|
||||
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dewormReminder).Error; err == nil && dewormReminder.NextDueDate != nil {
|
||||
dewormStatus = "下次 " + dewormReminder.NextDueDate.Format("1月2日")
|
||||
}
|
||||
|
||||
var anomalies int64
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordSymptom).Count(&anomalies)
|
||||
|
||||
return &HealthSummary{
|
||||
VaccineProgress: fmt.Sprintf("%d/3,即将到期", vaccineDone),
|
||||
DewormStatus: dewormStatus,
|
||||
WeightTrend: "稳定增长",
|
||||
AnomalyCount: anomalies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Poster 成长海报数据
|
||||
type Poster struct {
|
||||
PetName string `json:"pet_name"`
|
||||
PetEmoji string `json:"pet_emoji"`
|
||||
Age string `json:"age"`
|
||||
Weight string `json:"weight"`
|
||||
Stage string `json:"stage"`
|
||||
TasksCompleted int64 `json:"tasks_completed"`
|
||||
WeightRecords int64 `json:"weight_records"`
|
||||
VaccineRecords int64 `json:"vaccine_records"`
|
||||
HighRiskCount int64 `json:"high_risk_count"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
// GetPoster 生成海报聚合数据
|
||||
func (s *Service) GetPoster(userID, petID uint) (*Poster, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tasksDone, weightRecs, vaccineRecs int64
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND done = ?", petID, true).Count(&tasksDone)
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordWeight).Count(&weightRecs)
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineRecs)
|
||||
|
||||
return &Poster{
|
||||
PetName: pet.Name, PetEmoji: pet.Emoji, Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage,
|
||||
TasksCompleted: tasksDone, WeightRecords: weightRecs, VaccineRecords: vaccineRecs,
|
||||
HighRiskCount: 0, Headline: "稳定成长",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"github.com/sundynix/pets-be/internal/storage"
|
||||
)
|
||||
|
||||
// ErrNotFound 资源不存在(handler 据此返回 40400)
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Service 业务逻辑聚合,方法按领域分散在各文件
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
ai *ai.Engine
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, st *storage.Storage, cfg *config.Config, engine *ai.Engine) *Service {
|
||||
return &Service{db: db, storage: st, cfg: cfg, ai: engine}
|
||||
}
|
||||
|
||||
// ownedPet 校验宠物归属当前用户并返回
|
||||
func (s *Service) ownedPet(userID, petID uint) (*model.Pet, error) {
|
||||
var pet model.Pet
|
||||
err := s.db.Where("id = ? AND user_id = ?", petID, userID).First(&pet).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pet, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ListTasks 列出宠物任务(date 为空则取今天)
|
||||
func (s *Service) ListTasks(userID, petID uint, date *time.Time) ([]model.DailyTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := s.db.Where("pet_id = ?", petID)
|
||||
if date != nil {
|
||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
q = q.Where("task_date >= ? AND task_date < ?", start, end)
|
||||
}
|
||||
var tasks []model.DailyTask
|
||||
err := q.Order("id asc").Find(&tasks).Error
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
// ToggleTask 切换任务完成状态
|
||||
func (s *Service) ToggleTask(userID, taskID uint) (*model.DailyTask, error) {
|
||||
var task model.DailyTask
|
||||
if err := s.db.Where("id = ? AND user_id = ?", taskID, userID).First(&task).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
task.Done = !task.Done
|
||||
if task.Done {
|
||||
now := time.Now()
|
||||
task.CompletedAt = &now
|
||||
} else {
|
||||
task.CompletedAt = nil
|
||||
}
|
||||
if err := s.db.Save(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// CompleteAllTasks 完成宠物今日全部任务
|
||||
func (s *Service) CompleteAllTasks(userID, petID uint) ([]model.DailyTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
if err := s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
|
||||
Updates(map[string]any{"done": true, "completed_at": now}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListTasks(userID, petID, &now)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package service
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// jsonMarshalAny 便捷序列化(用于写入 datatypes.JSON 列)
|
||||
func jsonMarshalAny(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// Storage MinIO 对象存储封装
|
||||
type Storage struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
publicBaseURL string
|
||||
}
|
||||
|
||||
// New 初始化 MinIO 客户端,并确保 bucket 存在且可公开下载
|
||||
func New(cfg config.MinIOConfig) (*Storage, error) {
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio new: %w", err)
|
||||
}
|
||||
|
||||
s := &Storage{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/"),
|
||||
}
|
||||
if err := s.ensureBucket(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ensureBucket() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bucket exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil {
|
||||
return fmt.Errorf("make bucket: %w", err)
|
||||
}
|
||||
}
|
||||
// dev:设置公开只读策略,便于直接以 URL 访问对象
|
||||
policy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}]
|
||||
}`, s.bucket)
|
||||
if err := s.client.SetBucketPolicy(ctx, s.bucket, policy); err != nil {
|
||||
return fmt.Errorf("set bucket policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload 上传对象,返回可公开访问的 URL
|
||||
func (s *Storage) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("put object: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%s", s.publicBaseURL, s.bucket, objectName), nil
|
||||
}
|
||||
Reference in New Issue
Block a user