feat: 养护模板/社区运营/体重趋势/计划日历 + 雪花ID重构 + 文件表与图片上传

后端
- 主键改雪花字符串 ID(pkg/idgen + Base.BeforeCreate),全表外键/JWT/中间件随之调整
- 新增 files 表:/api/upload 按 MD5 去重,返回 {id,url,md5},服务端只收图片
- 头像/记录附图/帖子图改 file_id 关联,读取解析为 URL
- 养护模板(物种×阶段)后台可配 + AI 生成草稿;建档按模板生成任务/计划/提醒
- 社区 AI 运营:虚拟账号池 + 每日定时/手动生成,帖子带 AI 标
- 计划路线图节点带真实日期;首页周历与计划日历同源;新增 day-plan 当日安排
- 体重趋势接口带备注;记录列表分页

小程序
- 公共图片上传 utils/upload.js(仅图片);记录拍照/我的头像/社区发图三处接入
- 首页日历点选查当日任务;记录页体重趋势可点看备注 + 时间轴分页折叠
- 计划页日历点选查当日计划;自定义 tabBar 高度调整

后台(React)
- 新增「养护模板」「社区运营」页;用户管理加机器人筛选

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-06 08:48:15 +08:00
parent 609f7d06cf
commit 2f5dd6eefc
83 changed files with 2253 additions and 355 deletions
-11
View File
@@ -1,11 +0,0 @@
# Go
/bin/
*.out
# Node
web/admin/node_modules/
web/admin/.vite/
# 本地环境/日志
*.log
.env
+7
View File
@@ -40,6 +40,13 @@ func main() {
log.Printf("AI provider: %s", engine.ProviderName())
svc := service.New(db, st, cfg, engine)
if err := svc.SeedCareTemplates(); err != nil {
log.Fatalf("seed care templates: %v", err)
}
if err := svc.SeedCommunityBot(); err != nil {
log.Fatalf("seed community bot: %v", err)
}
svc.StartCommunityBot()
jm := appjwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
h := handler.New(svc, jm, cfg)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,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">
<script type="module" crossorigin src="/admin/assets/index-Wim1fK8F.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-CIpysjXR.css">
</head>
<body>
<div id="root"></div>
+121 -6
View File
@@ -5,6 +5,7 @@ import (
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
@@ -58,6 +59,7 @@ type adminListReq struct {
response.PageQuery
Keyword string `form:"keyword"`
Status string `form:"status"`
Type string `form:"type"`
}
// AdminListUsers GET /api/admin/users
@@ -65,7 +67,7 @@ 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())
users, total, err := h.svc.ListUsers(req.Keyword, req.Type, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
@@ -84,7 +86,7 @@ func (h *Handler) AdminSetUserDisabled(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetUserDisabled(uintParam(c, "id"), req.Disabled); err != nil {
if err := h.svc.SetUserDisabled(idParam(c, "id"), req.Disabled); err != nil {
response.FailErr(c, err)
return
}
@@ -128,7 +130,7 @@ func (h *Handler) AdminSetPostStatus(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetPostStatus(uintParam(c, "id"), req.Status); err != nil {
if err := h.svc.SetPostStatus(idParam(c, "id"), req.Status); err != nil {
respondErr(c, err)
return
}
@@ -150,7 +152,7 @@ func (h *Handler) AdminListComments(c *gin.Context) {
// AdminDeleteComment DELETE /api/admin/comments/:id
func (h *Handler) AdminDeleteComment(c *gin.Context) {
if err := h.svc.DeleteCommentAdmin(uintParam(c, "id")); err != nil {
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
@@ -171,7 +173,7 @@ func (h *Handler) AdminListArticles(c *gin.Context) {
}
type articleReq struct {
ID uint `json:"id"`
ID string `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
@@ -202,7 +204,7 @@ func (h *Handler) AdminSaveArticle(c *gin.Context) {
// AdminDeleteArticle DELETE /api/admin/articles/:id
func (h *Handler) AdminDeleteArticle(c *gin.Context) {
if err := h.svc.DeleteArticle(uintParam(c, "id")); err != nil {
if err := h.svc.DeleteArticle(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
@@ -221,3 +223,116 @@ func (h *Handler) AdminListPro(c *gin.Context) {
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
// AdminCareTemplateMeta GET /api/admin/care-templates/meta
// 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型
func (h *Handler) AdminCareTemplateMeta(c *gin.Context) {
response.OK(c, gin.H{
"species": []gin.H{
{"key": model.SpeciesCat, "label": "猫猫"},
{"key": model.SpeciesDog, "label": "狗狗"},
},
"stages": service.CareStages,
"task_sheets": []string{"", "weight", "poop", "food", "symptom", "vaccine", "medicine"},
"plan_sheets": []string{"", "weight", "vaccine", "medicine", "report", "taskDetail"},
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"},
})
}
// AdminGetCareTemplate GET /api/admin/care-templates?species=&stage=
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
data, err := h.svc.GetCareTemplate(c.Query("species"), c.Query("stage"))
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, data)
}
type careTemplateReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate PUT /api/admin/care-templates
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
var req careTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SaveCareTemplate(req.Species, req.Stage, req.Items); err != nil {
response.FailErr(c, err)
return
}
data, _ := h.svc.GetCareTemplate(req.Species, req.Stage)
response.OK(c, data)
}
type careGenReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
}
// AdminGenerateCareTemplate POST /api/admin/care-templates/generate
// AI 生成草稿(不落库),未开启 AI 时回退内置默认
func (h *Handler) AdminGenerateCareTemplate(c *gin.Context) {
var req careGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
items, err := h.svc.GenerateCareTemplate(req.Species, req.Stage)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
}
// AdminGetCommunityBot GET /api/admin/community-bot
func (h *Handler) AdminGetCommunityBot(c *gin.Context) {
response.OK(c, h.svc.GetCommunityBotConfig())
}
// AdminSaveCommunityBot PUT /api/admin/community-bot
func (h *Handler) AdminSaveCommunityBot(c *gin.Context) {
var req model.CommunityBotConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
saved, err := h.svc.SaveCommunityBotConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, saved)
}
type communityGenReq struct {
Count int `json:"count"`
}
// AdminGenerateCommunityPosts POST /api/admin/community-bot/generate
func (h *Handler) AdminGenerateCommunityPosts(c *gin.Context) {
var req communityGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Count <= 0 {
req.Count = 1
}
if req.Count > 30 {
req.Count = 30
}
made, err := h.svc.GenerateCommunityPosts(req.Count)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"made": made})
}
+7 -3
View File
@@ -89,9 +89,10 @@ func (h *Handler) Profile(c *gin.Context) {
}
type updateProfileReq struct {
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
Phone *string `json:"phone"`
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
AvatarFileID *string `json:"avatar_file_id"`
Phone *string `json:"phone"`
}
// UpdateProfile 更新资料
@@ -108,6 +109,9 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
if req.Avatar != nil {
fields["avatar"] = *req.Avatar
}
if req.AvatarFileID != nil {
fields["avatar_file_id"] = *req.AvatarFileID
}
if req.Phone != nil {
fields["phone"] = *req.Phone
}
+16 -10
View File
@@ -30,7 +30,7 @@ func (h *Handler) ListPosts(c *gin.Context) {
// GetPost GET /api/posts/:id
func (h *Handler) GetPost(c *gin.Context) {
post, err := h.svc.GetPost(uintParam(c, "id"))
post, err := h.svc.GetPost(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -39,11 +39,12 @@ func (h *Handler) GetPost(c *gin.Context) {
}
type postReq struct {
PetID *uint `json:"pet_id"`
Identity string `json:"identity"`
Content string `json:"content"`
Tags []string `json:"tags"`
Images []string `json:"images"`
PetID *string `json:"pet_id"`
Identity string `json:"identity"`
Content string `json:"content"`
Tags []string `json:"tags"`
Images []string `json:"images"`
ImageFileIDs []string `json:"image_file_ids"`
}
// CreatePost POST /api/posts
@@ -60,6 +61,11 @@ func (h *Handler) CreatePost(c *gin.Context) {
if b, err := jsonMarshal(req.Images); err == nil {
in.Images = datatypes.JSON(b)
}
if len(req.ImageFileIDs) > 0 {
if b, err := jsonMarshal(req.ImageFileIDs); err == nil {
in.ImageFileIDs = datatypes.JSON(b)
}
}
post, err := h.svc.CreatePost(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
@@ -70,7 +76,7 @@ func (h *Handler) CreatePost(c *gin.Context) {
// LikePost POST /api/posts/:id/like
func (h *Handler) LikePost(c *gin.Context) {
count, err := h.svc.LikePost(middleware.UserID(c), uintParam(c, "id"))
count, err := h.svc.LikePost(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -80,7 +86,7 @@ func (h *Handler) LikePost(c *gin.Context) {
// UnlikePost DELETE /api/posts/:id/like
func (h *Handler) UnlikePost(c *gin.Context) {
count, err := h.svc.UnlikePost(middleware.UserID(c), uintParam(c, "id"))
count, err := h.svc.UnlikePost(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -98,7 +104,7 @@ func (h *Handler) ListComments(c *gin.Context) {
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListComments(uintParam(c, "id"), req.Offset(), req.Limit())
comments, total, err := h.svc.ListComments(idParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
@@ -121,7 +127,7 @@ func (h *Handler) CreateComment(c *gin.Context) {
response.FailParams(c, "评论内容不能为空")
return
}
comment, err := h.svc.CreateComment(middleware.UserID(c), uintParam(c, "id"), req.Content)
comment, err := h.svc.CreateComment(middleware.UserID(c), idParam(c, "id"), req.Content)
if err != nil {
respondErr(c, err)
return
+3 -5
View File
@@ -3,7 +3,6 @@ package handler
import (
"encoding/json"
"errors"
"strconv"
"github.com/gin-gonic/gin"
@@ -12,10 +11,9 @@ import (
"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)
// idParam 路径参数(字符串雪花 ID
func idParam(c *gin.Context, key string) string {
return c.Param(key)
}
// jsonMarshal 便捷序列化
+4 -4
View File
@@ -20,7 +20,7 @@ func (h *Handler) ListArticles(c *gin.Context) {
// GetArticle GET /api/articles/:id
func (h *Handler) GetArticle(c *gin.Context) {
article, err := h.svc.GetArticle(uintParam(c, "id"))
article, err := h.svc.GetArticle(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -49,7 +49,7 @@ func (h *Handler) ActivatePro(c *gin.Context) {
}
type aiChatReq struct {
PetID *uint `json:"pet_id"`
PetID *string `json:"pet_id"`
Session string `json:"session"`
Text string `json:"text"`
}
@@ -75,7 +75,7 @@ func (h *Handler) AIChat(c *gin.Context) {
// 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"))
res, err := h.svc.GetHomeSummary(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -106,7 +106,7 @@ func (h *Handler) AssessSymptom(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
res, err := h.svc.AssessSymptom(middleware.UserID(c), uintParam(c, "id"), service.SymptomInput{
res, err := h.svc.AssessSymptom(middleware.UserID(c), idParam(c, "id"), service.SymptomInput{
Symptoms: req.Symptoms, Duration: req.Duration, Spirit: req.Spirit,
})
if err != nil {
+11 -6
View File
@@ -20,15 +20,17 @@ type petReq struct {
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Color string `json:"color"`
Breed string `json:"breed"`
Goals []string `json:"goals"`
Color string `json:"color"`
Breed string `json:"breed"`
AvatarFileID string `json:"avatar_file_id"`
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,
AvatarFileID: r.AvatarFileID,
}
if r.Birthday != "" {
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
@@ -70,7 +72,7 @@ func (h *Handler) CreatePet(c *gin.Context) {
// GetPet GET /api/pets/:id
func (h *Handler) GetPet(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), uintParam(c, "id"))
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -110,7 +112,10 @@ func (h *Handler) UpdatePet(c *gin.Context) {
if req.Breed != "" {
fields["breed"] = req.Breed
}
pet, err := h.svc.UpdatePet(middleware.UserID(c), uintParam(c, "id"), fields)
if req.AvatarFileID != "" {
fields["avatar_file_id"] = req.AvatarFileID
}
pet, err := h.svc.UpdatePet(middleware.UserID(c), idParam(c, "id"), fields)
if err != nil {
respondErr(c, err)
return
@@ -120,7 +125,7 @@ func (h *Handler) UpdatePet(c *gin.Context) {
// 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 {
if err := h.svc.DeletePet(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
+18 -8
View File
@@ -17,7 +17,7 @@ func (h *Handler) ListTasks(c *gin.Context) {
date = &t
}
}
tasks, err := h.svc.ListTasks(middleware.UserID(c), uintParam(c, "id"), date)
tasks, err := h.svc.ListTasks(middleware.UserID(c), idParam(c, "id"), date)
if err != nil {
respondErr(c, err)
return
@@ -27,7 +27,7 @@ func (h *Handler) ListTasks(c *gin.Context) {
// ToggleTask POST /api/tasks/:id/toggle
func (h *Handler) ToggleTask(c *gin.Context) {
task, err := h.svc.ToggleTask(middleware.UserID(c), uintParam(c, "id"))
task, err := h.svc.ToggleTask(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -37,7 +37,7 @@ func (h *Handler) ToggleTask(c *gin.Context) {
// 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"))
tasks, err := h.svc.CompleteAllTasks(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -47,7 +47,7 @@ func (h *Handler) CompleteAllTasks(c *gin.Context) {
// GetPlan GET /api/pets/:id/plan
func (h *Handler) GetPlan(c *gin.Context) {
plan, err := h.svc.GetPlan(middleware.UserID(c), uintParam(c, "id"))
plan, err := h.svc.GetPlan(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -55,6 +55,16 @@ func (h *Handler) GetPlan(c *gin.Context) {
response.OK(c, plan)
}
// DayPlan GET /api/pets/:id/day-plan?date=YYYY-MM-DD
func (h *Handler) DayPlan(c *gin.Context) {
res, err := h.svc.DayPlan(middleware.UserID(c), idParam(c, "id"), c.Query("date"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
// PlanCalendar GET /api/pets/:id/plan/calendar?month=YYYY-MM
func (h *Handler) PlanCalendar(c *gin.Context) {
now := time.Now()
@@ -64,7 +74,7 @@ func (h *Handler) PlanCalendar(c *gin.Context) {
year, month = t.Year(), int(t.Month())
}
}
res, err := h.svc.Calendar(middleware.UserID(c), uintParam(c, "id"), year, month)
res, err := h.svc.Calendar(middleware.UserID(c), idParam(c, "id"), year, month)
if err != nil {
respondErr(c, err)
return
@@ -83,7 +93,7 @@ func (h *Handler) CreateAIPlan(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
plan, err := h.svc.CreateAIPlan(middleware.UserID(c), uintParam(c, "id"), req.Input)
plan, err := h.svc.CreateAIPlan(middleware.UserID(c), idParam(c, "id"), req.Input)
if err != nil {
respondErr(c, err)
return
@@ -93,7 +103,7 @@ func (h *Handler) CreateAIPlan(c *gin.Context) {
// 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"))
plan, err := h.svc.ApplyAIPlan(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -103,7 +113,7 @@ func (h *Handler) ApplyAIPlan(c *gin.Context) {
// 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 {
if err := h.svc.TogglePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
+10 -6
View File
@@ -19,18 +19,22 @@ type recordReq struct {
NumValue float64 `json:"num_value"`
Category string `json:"category"`
ImageURL string `json:"image_url"`
ImageFileID string `json:"image_file_id"`
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"))
var pq response.PageQuery
_ = c.ShouldBindQuery(&pq)
pq.Normalize()
records, total, err := h.svc.ListRecords(middleware.UserID(c), idParam(c, "id"), c.Query("type"), pq.Offset(), pq.Limit())
if err != nil {
respondErr(c, err)
return
}
response.OK(c, records)
response.OK(c, response.NewPage(records, total, pq))
}
// CreateRecord POST /api/pets/:id/records
@@ -42,14 +46,14 @@ func (h *Handler) CreateRecord(c *gin.Context) {
}
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,
NumValue: req.NumValue, Category: req.Category, ImageURL: req.ImageURL, ImageFileID: req.ImageFileID, 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)
rec, err := h.svc.CreateRecord(middleware.UserID(c), idParam(c, "id"), in)
if err != nil {
respondErr(c, err)
return
@@ -59,7 +63,7 @@ func (h *Handler) CreateRecord(c *gin.Context) {
// 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 {
if err := h.svc.DeleteRecord(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
@@ -68,7 +72,7 @@ func (h *Handler) DeleteRecord(c *gin.Context) {
// 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)
points, err := h.svc.WeightTrend(middleware.UserID(c), idParam(c, "id"), 7)
if err != nil {
respondErr(c, err)
return
+4 -4
View File
@@ -19,7 +19,7 @@ type reminderReq struct {
// ListReminders GET /api/pets/:id/reminders
func (h *Handler) ListReminders(c *gin.Context) {
reminders, err := h.svc.ListReminders(middleware.UserID(c), uintParam(c, "id"))
reminders, err := h.svc.ListReminders(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -40,7 +40,7 @@ func (h *Handler) CreateReminder(c *gin.Context) {
in.NextDueDate = &t
}
}
r, err := h.svc.CreateReminder(middleware.UserID(c), uintParam(c, "id"), in)
r, err := h.svc.CreateReminder(middleware.UserID(c), idParam(c, "id"), in)
if err != nil {
respondErr(c, err)
return
@@ -67,7 +67,7 @@ func (h *Handler) UpdateReminder(c *gin.Context) {
fields["next_due_date"] = t
}
}
r, err := h.svc.UpdateReminder(middleware.UserID(c), uintParam(c, "id"), fields)
r, err := h.svc.UpdateReminder(middleware.UserID(c), idParam(c, "id"), fields)
if err != nil {
respondErr(c, err)
return
@@ -77,7 +77,7 @@ func (h *Handler) UpdateReminder(c *gin.Context) {
// 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 {
if err := h.svc.DeleteReminder(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
+4 -4
View File
@@ -9,7 +9,7 @@ import (
// 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"))
rep, err := h.svc.GetWeeklyReport(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -20,7 +20,7 @@ func (h *Handler) WeeklyReport(c *gin.Context) {
// 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)
bill, err := h.svc.GetBill(middleware.UserID(c), idParam(c, "id"), period)
if err != nil {
respondErr(c, err)
return
@@ -30,7 +30,7 @@ func (h *Handler) Bill(c *gin.Context) {
// 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"))
sum, err := h.svc.GetHealthSummary(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
@@ -40,7 +40,7 @@ func (h *Handler) HealthSummary(c *gin.Context) {
// Poster GET /api/pets/:id/poster
func (h *Handler) Poster(c *gin.Context) {
poster, err := h.svc.GetPoster(middleware.UserID(c), uintParam(c, "id"))
poster, err := h.svc.GetPoster(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
+4 -16
View File
@@ -1,18 +1,13 @@
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
// Upload POST /api/upload multipart/form-data,字段名 file
// 按 MD5 去重:相同内容直接返回已有文件,不重复上传。返回 {id,url,md5}。
func (h *Handler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
@@ -26,17 +21,10 @@ func (h *Handler) Upload(c *gin.Context) {
}
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)
f, err := h.svc.UploadFile(src, fileHeader.Filename, fileHeader.Header.Get("Content-Type"))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"url": url})
response.OK(c, gin.H{"id": f.ID, "url": f.URL, "md5": f.MD5})
}
+6 -6
View File
@@ -57,21 +57,21 @@ func AuthAdmin(jm *appjwt.Manager) gin.HandlerFunc {
}
// UserID 从上下文取当前用户 ID
func UserID(c *gin.Context) uint {
func UserID(c *gin.Context) string {
if v, ok := c.Get(CtxUserID); ok {
if id, ok := v.(uint); ok {
if id, ok := v.(string); ok {
return id
}
}
return 0
return ""
}
// AdminID 从上下文取当前管理员 ID
func AdminID(c *gin.Context) uint {
func AdminID(c *gin.Context) string {
if v, ok := c.Get(CtxAdminID); ok {
if id, ok := v.(uint); ok {
if id, ok := v.(string); ok {
return id
}
}
return 0
return ""
}
+2 -2
View File
@@ -3,8 +3,8 @@ package model
// AIMessage AI 聊天消息
type AIMessage struct {
Base
UserID uint `gorm:"index" json:"user_id"`
PetID *uint `json:"pet_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
PetID *string `gorm:"size:24" 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"`
+21 -3
View File
@@ -1,14 +1,28 @@
package model
import "time"
import (
"time"
// Base 所有模型的公共字段
"gorm.io/gorm"
"github.com/sundynix/pets-be/pkg/idgen"
)
// Base 所有模型的公共字段。主键为雪花算法字符串 ID(非自增)。
type Base struct {
ID uint `gorm:"primarykey" json:"id"`
ID string `gorm:"primaryKey;size:24" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate 未显式赋值时自动生成雪花 ID(通过嵌入对所有模型生效)。
func (b *Base) BeforeCreate(tx *gorm.DB) error {
if b.ID == "" {
b.ID = idgen.Next()
}
return nil
}
// AllModels 需要 AutoMigrate 的模型清单(按依赖顺序)
func AllModels() []any {
return []any{
@@ -27,5 +41,9 @@ func AllModels() []any {
&ProMembership{},
&Admin{},
&DailyAdvice{},
&CareTemplate{},
&CareTemplateItem{},
&CommunityBotConfig{},
&File{},
}
}
+48
View File
@@ -0,0 +1,48 @@
package model
// 养护模板:按「物种 × 阶段」配置建档时生成的今日任务 / 30天计划 / 提醒。
// 管理员可 AI 生成草稿后修改保存;建档时按宠物物种阶段套用,查不到回退内置默认。
// 物种取值
const (
SpeciesCat = "cat"
SpeciesDog = "dog"
)
// 模板明细的类别
const (
CareKindTask = "task" // 今日任务
CareKindPlan = "plan" // 30天计划节点
CareKindReminder = "reminder" // 提醒
)
// CareTemplate 一套模板 = 一个 (species, stage) 组合
type CareTemplate struct {
Base
Species string `gorm:"size:16;uniqueIndex:idx_species_stage" json:"species"` // cat | dog
Stage string `gorm:"size:48;uniqueIndex:idx_species_stage" json:"stage"` // 与 onboarding 阶段一致
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
}
// CareTemplateItem 模板明细,kind 区分三类;不同类只用到部分字段
type CareTemplateItem struct {
Base
TemplateID string `gorm:"size:24;index" json:"template_id"`
Kind string `gorm:"size:16;index" json:"kind"` // task | plan | reminder
Sort int `json:"sort"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
// task 专用
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空)
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录弹层:weight/poop/food/vaccine...
// plan 专用
Day int `json:"day"` // 第 N 天(0=今天)
DayLabel string `gorm:"size:32" json:"day_label"` // 展示标签
// reminder 专用
ReminderType string `gorm:"size:32" json:"reminder_type"` // vaccine/deworm/weight/monthlyReport
OffsetDays int `json:"offset_days"` // 到期 = 今天 + N 天;<=0 表示无固定到期,用频率
Frequency string `gorm:"size:64" json:"frequency"` // 频率描述,如「每周二、周五」
}
+2 -2
View File
@@ -3,8 +3,8 @@ package model
// Comment 帖子评论
type Comment struct {
Base
PostID uint `gorm:"index" json:"post_id"`
UserID uint `gorm:"index" json:"user_id"`
PostID string `gorm:"size:24;index" json:"post_id"`
UserID string `gorm:"size:24;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"`
+10
View File
@@ -0,0 +1,10 @@
package model
// CommunityBotConfig 社区 AI 运营配置(单例,固定 id=1)
type CommunityBotConfig struct {
Base
Enabled bool `json:"enabled"` // 是否开启每日自动生成
DailyCount int `json:"daily_count"` // 每天生成条数
StartHour int `json:"start_hour"` // 白天投放窗口起(含)
EndHour int `json:"end_hour"` // 白天投放窗口止(不含)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package model
// DailyAdvice 首页「今日建议」每宠每天缓存一条,避免重复调用模型
type DailyAdvice struct {
Base
PetID uint `gorm:"uniqueIndex:idx_pet_day" json:"pet_id"`
PetID string `gorm:"size:24;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"`
}
+3 -3
View File
@@ -5,9 +5,9 @@ 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"`
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
TaskDate time.Time `gorm:"size:24;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"` // "" / 重要
+12
View File
@@ -0,0 +1,12 @@
package model
// File 上传文件表:图片/头像等统一入库,按 MD5 去重,使用处以 file_id 关联。
type File struct {
Base
MD5 string `gorm:"size:32;uniqueIndex" json:"md5"` // 内容 MD5,唯一,用于去重
ObjectName string `gorm:"size:255" json:"object_name"` // MinIO 对象名
URL string `gorm:"size:512" json:"url"` // 可访问 URL
Size int64 `json:"size"`
ContentType string `gorm:"size:128" json:"content_type"`
Ext string `gorm:"size:16" json:"ext"`
}
+4 -3
View File
@@ -23,8 +23,8 @@ const (
// NumValue 存体重/金额,Category 存消费类别/便便状态等,Extra 存各类型专有字段。
type HealthRecord struct {
Base
PetID uint `gorm:"index" json:"pet_id"`
UserID uint `gorm:"index" json:"user_id"`
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;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"`
@@ -32,6 +32,7 @@ type HealthRecord struct {
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"`
ImageFileID string `gorm:"size:24" json:"image_file_id"` // 关联 files 表:记录附图
Extra datatypes.JSON `json:"extra"`
OccurredAt time.Time `gorm:"index" json:"occurred_at"`
OccurredAt time.Time `gorm:"size:24;index" json:"occurred_at"`
}
+3 -1
View File
@@ -9,9 +9,11 @@ import (
// Pet 宠物,属于 User,支持多宠
type Pet struct {
Base
UserID uint `gorm:"index" json:"user_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
Name string `gorm:"size:64" json:"name"`
Emoji string `gorm:"size:16" json:"emoji"`
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表:宠物照片
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
Birthday *time.Time `json:"birthday"`
+4 -3
View File
@@ -15,8 +15,8 @@ const (
// Plan 养宠计划(30 天路线图 / AI 计划)
type Plan struct {
Base
PetID uint `gorm:"index" json:"pet_id"`
UserID uint `gorm:"index" json:"user_id"`
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;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"`
@@ -31,11 +31,12 @@ type Plan struct {
// PlanTask 计划明细项
type PlanTask struct {
Base
PlanID uint `gorm:"index" json:"plan_id"`
PlanID string `gorm:"size:24;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"`
Date string `gorm:"-" json:"date"` // 计算字段:计划开始日 + Day
}
+5 -3
View File
@@ -12,15 +12,17 @@ const (
// Post 社区帖子
type Post struct {
Base
UserID uint `gorm:"index" json:"user_id"`
PetID *uint `json:"pet_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
PetID *string `gorm:"size:24" 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"`
Images datatypes.JSON `json:"images"` // 展示用:URL 数组(真实图)或 emoji 数组(机器人)
ImageFileIDs datatypes.JSON `json:"image_file_ids"` // 关联 files 表的 id 数组,读取时解析为 Images
LikeCount int `json:"like_count"`
CommentCount int `json:"comment_count"`
Status string `gorm:"size:16;default:published;index" json:"status"`
IsAI bool `gorm:"size:24;index" json:"is_ai"` // AI 运营生成的帖子,前端打「AI」标
}
+2 -2
View File
@@ -3,6 +3,6 @@ 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"`
PostID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"post_id"`
UserID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"user_id"`
}
+1 -1
View File
@@ -12,7 +12,7 @@ const (
// ProMembership 会员,1:1 于 User
type ProMembership struct {
Base
UserID uint `gorm:"uniqueIndex" json:"user_id"`
UserID string `gorm:"size:24;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"`
+2 -2
View File
@@ -13,8 +13,8 @@ const (
// Reminder 提醒
type Reminder struct {
Base
PetID uint `gorm:"index" json:"pet_id"`
UserID uint `gorm:"index" json:"user_id"`
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;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"`
+5 -2
View File
@@ -4,9 +4,12 @@ package model
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"`
Nickname string `gorm:"size:64" json:"nickname"`
Avatar string `gorm:"size:512" json:"avatar"`
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表;优先于 Avatar
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
Phone string `gorm:"size:32" json:"phone"`
Onboarded bool `json:"onboarded"`
Disabled bool `json:"disabled"`
IsBot bool `gorm:"size:24;index" json:"is_bot"` // 虚拟宠友账号(AI 社区运营用)
}
+10
View File
@@ -66,6 +66,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/plan", h.GetPlan)
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
g.GET("/pets/:id/day-plan", h.DayPlan)
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
g.POST("/plan-tasks/:id/toggle", h.TogglePlanTask)
@@ -124,5 +125,14 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.POST("/articles", h.AdminSaveArticle)
g.DELETE("/articles/:id", h.AdminDeleteArticle)
g.GET("/care-templates/meta", h.AdminCareTemplateMeta)
g.GET("/care-templates", h.AdminGetCareTemplate)
g.PUT("/care-templates", h.AdminSaveCareTemplate)
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
g.GET("/community-bot", h.AdminGetCommunityBot)
g.PUT("/community-bot", h.AdminSaveCommunityBot)
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
g.GET("/memberships", h.AdminListPro)
}
+17 -8
View File
@@ -6,8 +6,11 @@ import (
"github.com/sundynix/pets-be/internal/model"
)
// AdminLogin 校验管理员账号密码
// AdminLogin 校验管理员账号密码。仅允许配置里指定的唯一管理员账号登录后台。
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
if username != s.cfg.Admin.Username {
return nil, ErrNotFound
}
var admin model.Admin
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
return nil, ErrNotFound
@@ -22,7 +25,7 @@ func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
}
// GetAdmin 取管理员
func (s *Service) GetAdmin(id uint) (*model.Admin, error) {
func (s *Service) GetAdmin(id string) (*model.Admin, error) {
var admin model.Admin
if err := s.db.First(&admin, id).Error; err != nil {
return nil, ErrNotFound
@@ -49,11 +52,17 @@ func (s *Service) Stats() (*AdminStats, error) {
}
// ListUsers 用户分页(keyword 匹配昵称)
func (s *Service) ListUsers(keyword string, offset, limit int) ([]model.User, int64, error) {
func (s *Service) ListUsers(keyword, userType string, offset, limit int) ([]model.User, int64, error) {
q := s.db.Model(&model.User{})
if keyword != "" {
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
}
switch userType {
case "bot":
q = q.Where("is_bot = ?", true)
case "real":
q = q.Where("is_bot IS NULL OR is_bot = ?", false)
}
var total int64
q.Count(&total)
var users []model.User
@@ -62,7 +71,7 @@ func (s *Service) ListUsers(keyword string, offset, limit int) ([]model.User, in
}
// SetUserDisabled 启用/禁用用户
func (s *Service) SetUserDisabled(id uint, disabled bool) error {
func (s *Service) SetUserDisabled(id string, disabled bool) error {
return s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error
}
@@ -89,7 +98,7 @@ func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post
}
// SetPostStatus 审核帖子(published/hidden/deleted
func (s *Service) SetPostStatus(id uint, status string) error {
func (s *Service) SetPostStatus(id string, status string) error {
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return res.Error
@@ -110,7 +119,7 @@ func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64,
}
// DeleteCommentAdmin 删除评论
func (s *Service) DeleteCommentAdmin(id uint) error {
func (s *Service) DeleteCommentAdmin(id string) error {
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
}
@@ -125,7 +134,7 @@ func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64,
// SaveArticle 新增或更新文章(ID 为 0 则新增)
func (s *Service) SaveArticle(a *model.Article) error {
if a.ID == 0 {
if a.ID == "" {
return s.db.Create(a).Error
}
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
@@ -140,7 +149,7 @@ func (s *Service) SaveArticle(a *model.Article) error {
}
// DeleteArticle 删除文章
func (s *Service) DeleteArticle(id uint) error {
func (s *Service) DeleteArticle(id string) error {
res := s.db.Delete(&model.Article{}, id)
if res.Error != nil {
return res.Error
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
func (s *Service) AIChat(userID uint, petID *uint, session, text string) (string, error) {
func (s *Service) AIChat(userID string, petID *string, session, text string) (string, error) {
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
if s.ai != nil && s.ai.Enabled() {
if r, err := s.llmChat(petID, text); err == nil && r != "" {
+3 -3
View File
@@ -17,7 +17,7 @@ const aiSafetyPrompt = `你是「毛孩子计划」的养宠助手,服务新
4. 语气亲切、简洁,用中文,避免长篇大论。`
// petBrief 组装宠物档案 + 近期记录的上下文文本
func (s *Service) petBrief(petID uint) string {
func (s *Service) petBrief(petID string) string {
var pet model.Pet
if err := s.db.First(&pet, petID).Error; err != nil {
return ""
@@ -41,7 +41,7 @@ func (s *Service) petBrief(petID uint) string {
}
// llmChat 真实模型聊天回复
func (s *Service) llmChat(petID *uint, text string) (string, error) {
func (s *Service) llmChat(petID *string, text string) (string, error) {
system := aiSafetyPrompt
if petID != nil {
if brief := s.petBrief(*petID); brief != "" {
@@ -67,7 +67,7 @@ type SymptomResult struct {
}
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
func (s *Service) AssessSymptom(userID, petID uint, in SymptomInput) (*SymptomResult, error) {
func (s *Service) AssessSymptom(userID, petID string, in SymptomInput) (*SymptomResult, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
+1 -1
View File
@@ -14,7 +14,7 @@ func (s *Service) ListArticles() ([]model.Article, error) {
}
// GetArticle 文章详情
func (s *Service) GetArticle(id uint) (*model.Article, error) {
func (s *Service) GetArticle(id string) (*model.Article, error) {
var a model.Article
if err := s.db.First(&a, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
+3 -2
View File
@@ -102,7 +102,7 @@ func (s *Service) WechatLogin(code string) (*model.User, error) {
}
// GetUser 取用户
func (s *Service) GetUser(userID uint) (*model.User, error) {
func (s *Service) GetUser(userID string) (*model.User, error) {
var u model.User
if err := s.db.First(&u, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -110,11 +110,12 @@ func (s *Service) GetUser(userID uint) (*model.User, error) {
}
return nil, err
}
u.AvatarURL = s.fileURL(u.AvatarFileID)
return &u, nil
}
// UpdateUser 更新用户资料(昵称/头像/手机号)
func (s *Service) UpdateUser(userID uint, fields map[string]any) (*model.User, error) {
func (s *Service) UpdateUser(userID string, 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
}
+278
View File
@@ -0,0 +1,278 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
// CareSpecies / CareStages 支持的物种与阶段(阶段字符串与 onboarding 一致)
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
var CareStages = []string{"刚到家 0-30 天", "幼年期", "成年期", "老年期"}
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
func SpeciesOf(petType string) string {
if strings.Contains(petType, "狗") {
return model.SpeciesDog
}
return model.SpeciesCat
}
func validSpecies(s string) bool { return s == model.SpeciesCat || s == model.SpeciesDog }
func validStage(s string) bool {
for _, x := range CareStages {
if x == s {
return true
}
}
return false
}
// careTemplateItems 取某组合的模板明细:DB 优先,无则回退内置默认
func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateItem {
var tpl model.CareTemplate
err := s.db.
Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
if err == nil && len(tpl.Items) > 0 {
return tpl.Items
}
return builtinCareItems(species, stage)
}
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
func (s *Service) GetCareTemplate(species, stage string) (map[string]any, error) {
if !validSpecies(species) || !validStage(stage) {
return nil, errors.New("invalid species or stage")
}
return map[string]any{
"species": species,
"stage": stage,
"items": s.careTemplateItems(species, stage),
}, nil
}
// SaveCareTemplate 后台保存:整表替换该组合的明细
func (s *Service) SaveCareTemplate(species, stage string, items []model.CareTemplateItem) error {
if !validSpecies(species) || !validStage(stage) {
return errors.New("invalid species or stage")
}
return s.db.Transaction(func(tx *gorm.DB) error {
var tpl model.CareTemplate
err := tx.Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
tpl = model.CareTemplate{Species: species, Stage: stage}
if err := tx.Create(&tpl).Error; err != nil {
return err
}
} else if err != nil {
return err
}
if err := tx.Where("template_id = ?", tpl.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
clean := make([]model.CareTemplateItem, 0, len(items))
for i := range items {
it := items[i]
if strings.TrimSpace(it.Title) == "" {
continue
}
it.ID = ""
it.TemplateID = tpl.ID
it.Sort = i
clean = append(clean, it)
}
if len(clean) > 0 {
if err := tx.Create(&clean).Error; err != nil {
return err
}
}
return nil
})
}
// GenerateCareTemplate 后台「AI 生成草稿」:不落库,仅返回建议明细。
// AI 未开启或失败时回退内置默认,保证总有可用草稿。
func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTemplateItem, error) {
if !validSpecies(species) || !validStage(stage) {
return nil, errors.New("invalid species or stage")
}
if !s.ai.Enabled() {
return builtinCareItems(species, stage), nil
}
spName := "猫"
if species == model.SpeciesDog {
spName = "狗"
}
system := aiSafetyPrompt + `
你现在为后台生成一套「养护模板」,用于新宠建档时自动生成任务/计划/提醒。
只输出 JSON,结构如下(不要多余文字):
{
"tasks": [{"title":"","description":"","priority":"重要或空","sheet_type":"weight|poop|food|symptom|vaccine|medicine 或空"}],
"plan": [{"day":0,"day_label":"今天","title":"","description":"","sheet_type":"weight|vaccine|medicine|report|taskDetail 或空"}],
"reminders":[{"reminder_type":"vaccine|deworm|weight|monthlyReport","title":"","offset_days":18,"frequency":"到期用天数则填0并写频率,如每周二五"}]
}
要求:tasks 3 条、plan 4 条、reminders 3-4 条;贴合该物种与阶段的真实照护节奏(如成年比幼年疫苗/驱虫间隔更长,狗驱虫比猫更频繁)。`
user := fmt.Sprintf("为【%s · %s】生成一套养护模板。", spName, stage)
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return builtinCareItems(species, stage), nil
}
items, ok := parseCareItems(out)
if !ok || len(items) == 0 {
return builtinCareItems(species, stage), nil
}
return items, nil
}
// parseCareItems 解析 AI 返回的分组 JSON → 扁平明细
func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
var raw struct {
Tasks []struct {
Title, Description, Priority, SheetType string
} `json:"tasks"`
Plan []struct {
Day int
DayLabel, Title, Description, SheetType string
} `json:"plan"`
Reminders []struct {
ReminderType, Title, Frequency string
OffsetDays int
} `json:"reminders"`
}
if err := json.Unmarshal([]byte(extractJSON(out)), &raw); err != nil {
return nil, false
}
var items []model.CareTemplateItem
for _, t := range raw.Tasks {
if t.Title == "" {
continue
}
items = append(items, careTask(t.Title, t.Description, t.Priority, t.SheetType))
}
for _, p := range raw.Plan {
if p.Title == "" {
continue
}
items = append(items, carePlan(p.Day, p.DayLabel, p.Title, p.Description, p.SheetType))
}
for _, r := range raw.Reminders {
if r.Title == "" {
continue
}
items = append(items, careReminder(r.ReminderType, r.Title, r.OffsetDays, r.Frequency))
}
return items, len(items) > 0
}
// SeedCareTemplates 启动幂等 seed:库中无任何模板时写入 8 套内置默认
func (s *Service) SeedCareTemplates() error {
var count int64
if err := s.db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
for _, sp := range CareSpecies {
for _, stage := range CareStages {
tpl := model.CareTemplate{Species: sp, Stage: stage, Items: builtinCareItems(sp, stage)}
if err := s.db.Create(&tpl).Error; err != nil {
return err
}
}
}
return nil
}
// ---- 明细构造小工具 ----
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindTask, Title: title, Description: desc, Priority: priority, SheetType: sheet}
}
func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
}
func careReminder(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype, Title: title, OffsetDays: offset, Frequency: freq}
}
// builtinCareItems 内置默认模板(8 套),既作开箱数据也作兜底
func builtinCareItems(species, stage string) []model.CareTemplateItem {
isDog := species == model.SpeciesDog
dewormAdult := 90
if isDog {
dewormAdult = 60 // 狗驱虫间隔通常比猫短
}
switch stage {
case "刚到家 0-30 天":
return []model.CareTemplateItem{
careTask("记录一次体重", "到家后每周记录,建立基线", "重要", "weight"),
careTask("观察饮水与排便", "换环境应激常见软便,连续观察几天", "", "poop"),
careTask("布置安静休息角", "固定饮食、厕所与休息区,减少应激", "", ""),
carePlan(0, "今天", "安顿与环境适应", "固定饮食、厕所、休息区位置", "taskDetail"),
carePlan(3, "第3天", "观察排便与食欲", "记录应激缓解情况", "poop"),
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
carePlan(14, "第14天", "预约首次体检/疫苗", "确认医院与时间", "vaccine"),
careReminder("vaccine", "首次疫苗接种", 14, ""),
careReminder("deworm", "首次体内外驱虫", 7, ""),
careReminder("weight", "体重记录", 0, "每周一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "幼年期":
vaccineTitle := "第 2 针疫苗"
if isDog {
vaccineTitle = "下一针联苗"
}
return []model.CareTemplateItem{
careTask("记录一次体重", "幼年期每周至少记录 2 次", "重要", "weight"),
careTask("观察饮水与排便", "换粮、应激都可能影响排便状态", "", "poop"),
careTask("检查疫苗预约", "按免疫程序别漏针", "重要", "vaccine"),
carePlan(0, "今天", "观察排便状态", "记录颜色、形态、次数", "taskDetail"),
carePlan(1, "明天", "检查疫苗预约", "确认下一针医院与时间", "vaccine"),
carePlan(7, "第7天", "体重趋势检查", "每周称重,观察稳定增长", "weight"),
carePlan(14, "第14天", "复盘饮食与便便", "换粮记录与异常一起看", "taskDetail"),
careReminder("vaccine", vaccineTitle, 18, ""),
careReminder("deworm", "体内外驱虫", 12, ""),
careReminder("weight", "体重记录", 0, "每周二、周五"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
case "老年期":
return []model.CareTemplateItem{
careTask("记录一次体重", "老年期体重波动要重视", "重要", "weight"),
careTask("观察精神与食欲", "明显变差要尽快就医", "重要", "symptom"),
careTask("关注饮水量", "多饮多尿可能是慢病信号", "", ""),
carePlan(0, "今天", "老年健康自查", "精神、食欲、饮水一起看", "taskDetail"),
carePlan(7, "第7天", "体重与饮水记录", "留意慢病早期信号", "weight"),
carePlan(14, "第14天", "用药与体检回顾", "慢病管理与用药记录", "medicine"),
carePlan(30, "第30天", "生成月度报告", "整理给医生看的记录", "report"),
careReminder("vaccine", "年度疫苗加强", 30, ""),
careReminder("deworm", "体内外驱虫", dewormAdult, ""),
careReminder("weight", "体重记录", 0, "每两周一次"),
careReminder("monthlyReport", "月度报告 / 建议体检", 0, "每月 1 日"),
}
default: // 成年期
return []model.CareTemplateItem{
careTask("记录一次体重", "成年期每月称重一次即可", "", "weight"),
careTask("观察饮水与排便", "发现软便可连续观察几天", "", "poop"),
careTask("梳毛与口腔检查", "关注牙结石与皮肤状态", "", ""),
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛过一遍", "taskDetail"),
carePlan(7, "第7天", "体重趋势检查", "对比上月,观察是否稳定", "weight"),
carePlan(14, "第14天", "驱虫记录复盘", "确认体内外驱虫是否到期", "medicine"),
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
careReminder("vaccine", "年度疫苗加强", 30, ""),
careReminder("deworm", "体内外驱虫", dewormAdult, ""),
careReminder("weight", "体重记录", 0, "每月一次"),
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
}
}
}
+58 -15
View File
@@ -1,12 +1,49 @@
package service
import (
"encoding/json"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// attachPostImages 把帖子的 ImageFileIDs 解析为可展示的 ImagesURL 数组)。
// 机器人帖 ImageFileIDs 为空,保留其 Imagesemoji)不变。
func (s *Service) attachPostImages(posts []model.Post) {
perPost := make([][]string, len(posts))
all := []string{}
for i := range posts {
if len(posts[i].ImageFileIDs) == 0 {
continue
}
var ids []string
if json.Unmarshal(posts[i].ImageFileIDs, &ids) == nil && len(ids) > 0 {
perPost[i] = ids
all = append(all, ids...)
}
}
if len(all) == 0 {
return
}
urlMap := s.fileURLs(all)
for i := range posts {
if len(perPost[i]) == 0 {
continue
}
urls := make([]string, 0, len(perPost[i]))
for _, id := range perPost[i] {
if u := urlMap[id]; u != "" {
urls = append(urls, u)
}
}
if b, err := json.Marshal(urls); err == nil {
posts[i].Images = datatypes.JSON(b)
}
}
}
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
func tabTag(tab string) string {
switch tab {
@@ -35,11 +72,12 @@ func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64,
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
return nil, 0, err
}
s.attachPostImages(posts)
return posts, total, nil
}
// GetPost 帖子详情
func (s *Service) GetPost(postID uint) (*model.Post, error) {
func (s *Service) GetPost(postID string) (*model.Post, error) {
var p model.Post
if err := s.db.First(&p, postID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -47,20 +85,23 @@ func (s *Service) GetPost(postID uint) (*model.Post, error) {
}
return nil, err
}
return &p, nil
one := []model.Post{p}
s.attachPostImages(one)
return &one[0], nil
}
// PostInput 发帖入参
type PostInput struct {
PetID *uint
Identity string
Content string
Tags datatypes.JSON
Images datatypes.JSON
PetID *string
Identity string
Content string
Tags datatypes.JSON
Images datatypes.JSON
ImageFileIDs datatypes.JSON
}
// CreatePost 发帖
func (s *Service) CreatePost(userID uint, in PostInput) (*model.Post, error) {
func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) {
authorName := "匿名宠友"
authorEmoji := "🐾"
switch in.Identity {
@@ -78,16 +119,18 @@ func (s *Service) CreatePost(userID uint, in PostInput) (*model.Post, error) {
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,
ImageFileIDs: in.ImageFileIDs, Status: model.PostPublished,
}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
return &p, nil
one := []model.Post{p}
s.attachPostImages(one)
return &one[0], nil
}
// LikePost 点赞(幂等:已赞则不重复计数)
func (s *Service) LikePost(userID, postID uint) (int, error) {
func (s *Service) LikePost(userID, postID string) (int, error) {
if _, err := s.GetPost(postID); err != nil {
return 0, err
}
@@ -110,7 +153,7 @@ func (s *Service) LikePost(userID, postID uint) (int, error) {
}
// UnlikePost 取消点赞
func (s *Service) UnlikePost(userID, postID uint) (int, error) {
func (s *Service) UnlikePost(userID, postID string) (int, error) {
if _, err := s.GetPost(postID); err != nil {
return 0, err
}
@@ -131,7 +174,7 @@ func (s *Service) UnlikePost(userID, postID uint) (int, error) {
return s.postLikeCount(postID)
}
func (s *Service) postLikeCount(postID uint) (int, error) {
func (s *Service) postLikeCount(postID string) (int, error) {
var p model.Post
if err := s.db.Select("like_count").First(&p, postID).Error; err != nil {
return 0, err
@@ -140,7 +183,7 @@ func (s *Service) postLikeCount(postID uint) (int, error) {
}
// ListComments 评论分页
func (s *Service) ListComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
func (s *Service) ListComments(postID string, 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 {
@@ -154,7 +197,7 @@ func (s *Service) ListComments(postID uint, offset, limit int) ([]model.Comment,
}
// CreateComment 评论
func (s *Service) CreateComment(userID, postID uint, content string) (*model.Comment, error) {
func (s *Service) CreateComment(userID, postID string, content string) (*model.Comment, error) {
if _, err := s.GetPost(postID); err != nil {
return nil, err
}
+247
View File
@@ -0,0 +1,247 @@
package service
import (
"encoding/json"
"fmt"
"math/rand"
"time"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
const botTickMinutes = 15
// 话题种子,增加生成多样性
var botTopics = []string{
"新手第一次给猫换粮的经历和注意点",
"幼犬疫苗期间的日常照护",
"猫咪软便观察和调理的经验",
"遛狗时防止捡食的小技巧",
"换季掉毛严重怎么护理",
"刚到家的猫如何度过应激期",
"给狗狗刷牙、清口腔的经验",
"记录体重时发现的小变化",
"驱虫到期提醒和实操",
"多猫家庭的相处磨合",
"老年猫饮水和肾脏健康关注点",
"幼猫社会化和玩耍引导",
}
// 默认虚拟宠友账号池(昵称 + emoji 头像)
var botSeeds = []struct{ Name, Emoji string }{
{"团子的日常", "🐱"}, {"布丁妈妈", "🐶"}, {"三花小报告", "🐱"},
{"柯基屁股蛋", "🐶"}, {"橘座驾到", "🐱"}, {"奶牛猫饲养员", "🐮"},
{"金毛暖暖", "🦮"}, {"布偶少爷", "😺"}, {"英短小灰灰", "🐱"},
{"泰迪不乖", "🐩"}, {"狸花猫巡逻队", "🐈"}, {"雪纳瑞老张", "🐶"},
{"银渐层日记", "😸"}, {"哈士奇拆家现场", "🐺"}, {"暹罗小王子", "🐱"},
{"边牧上学记", "🐕"}, {"美短起司", "🧀"}, {"柴犬麻薯", "🐕"},
{"无毛猫斯芬克斯", "🐱"}, {"比熊棉花糖", "🐶"}, {"缅因大橘", "🦁"},
{"腊肠一米五", "🌭"}, {"加菲脸圆圆", "😻"}, {"萨摩耶微笑", "🐻‍❄️"},
{"蓝猫布加迪", "🐱"}, {"法斗打呼噜", "🐶"}, {"折耳软软", "🐱"},
{"阿拉斯加大脸", "🐺"},
}
// 兜底文案(AI 未开启/失败时用)
var fallbackPosts = []string{
"换粮第3天,软便基本好了,7天过渡法真的有用,分享给同样在换粮的姐妹~",
"幼猫社会化真的重要,每天陪玩20分钟,现在一点都不怕生人了🐱",
"提醒大家驱虫别忘记,我差点漏了,手机日历设个提醒最保险。",
"金毛换季掉毛太夸张了,每天梳一次+补点卵磷脂,情况好多了。",
}
// getBotConfig 取单例配置(不存在返回内置默认,不写库)
func (s *Service) getBotConfig() model.CommunityBotConfig {
var c model.CommunityBotConfig
if err := s.db.First(&c, "1").Error; err != nil {
return model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
}
return c
}
// GetCommunityBotConfig 后台读取配置
func (s *Service) GetCommunityBotConfig() model.CommunityBotConfig {
return s.getBotConfig()
}
// SaveCommunityBotConfig 后台保存配置(固定 id=1 upsert
func (s *Service) SaveCommunityBotConfig(in model.CommunityBotConfig) (model.CommunityBotConfig, error) {
if in.DailyCount < 0 {
in.DailyCount = 0
}
if in.StartHour < 0 || in.StartHour > 23 {
in.StartHour = 9
}
if in.EndHour < 1 || in.EndHour > 24 {
in.EndHour = 21
}
if in.EndHour <= in.StartHour {
in.EndHour = in.StartHour + 1
}
in.ID = "1"
if err := s.db.Save(&in).Error; err != nil {
return in, err
}
return in, nil
}
// SeedCommunityBot 启动幂等 seed:虚拟账号池(按昵称补齐新增账号)+ 配置单例
func (s *Service) SeedCommunityBot() error {
// 迁移前的旧用户 is_bot 为 NULL,回填为 false 便于筛选
if err := s.db.Model(&model.User{}).Where("is_bot IS NULL").Update("is_bot", false).Error; err != nil {
return err
}
for _, b := range botSeeds {
var n int64
if err := s.db.Model(&model.User{}).Where("nickname = ? AND is_bot = ?", b.Name, true).Count(&n).Error; err != nil {
return err
}
if n == 0 {
u := model.User{Nickname: b.Name, Avatar: b.Emoji, IsBot: true, Onboarded: true}
if err := s.db.Create(&u).Error; err != nil {
return err
}
}
}
var cfgCount int64
if err := s.db.Model(&model.CommunityBotConfig{}).Count(&cfgCount).Error; err != nil {
return err
}
if cfgCount == 0 {
c := model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
c.ID = "1"
if err := s.db.Create(&c).Error; err != nil {
return err
}
}
return nil
}
// countAIPostsToday 今天已生成的 AI 帖数
func (s *Service) countAIPostsToday() int64 {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var n int64
s.db.Model(&model.Post{}).Where("is_ai = ? AND created_at >= ?", true, start).Count(&n)
return n
}
// GenerateCommunityPosts 生成 n 条 AI 帖并入库,返回实际生成数
func (s *Service) GenerateCommunityPosts(n int) (int, error) {
if n <= 0 {
return 0, nil
}
var bots []model.User
if err := s.db.Where("is_bot = ?", true).Find(&bots).Error; err != nil {
return 0, err
}
if len(bots) == 0 {
return 0, nil
}
made := 0
for i := 0; i < n; i++ {
bot := bots[rand.Intn(len(bots))]
content, tag := s.genOnePostContent()
if content == "" {
continue
}
tagsJSON, _ := json.Marshal([]string{tag})
imagesJSON, _ := json.Marshal(pickPhotos())
p := model.Post{
UserID: bot.ID, AuthorName: bot.Nickname, AuthorEmoji: bot.Avatar,
Identity: "petName", Content: content, Tags: datatypes.JSON(tagsJSON),
Images: datatypes.JSON(imagesJSON),
LikeCount: rand.Intn(40), Status: model.PostPublished, IsAI: true,
}
if err := s.db.Create(&p).Error; err != nil {
return made, err
}
made++
}
return made, nil
}
// 配图 emoji 池(App 用 emoji 当照片渲染),当作帖子的「图」
var photoPool = []string{"🐱", "🐶", "🐈", "🐕", "🍚", "🦴", "🧶", "🪀", "🏠", "🌿", "🛁", "💊", "🐾", "🍗", "🥩", "🧸", "☀️", "🛏️"}
// pickPhotos ~65% 概率随机贴 1-3 张配图,其余纯文字(更自然)
func pickPhotos() []string {
if rand.Float64() > 0.65 {
return []string{}
}
n := 1 + rand.Intn(3)
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, photoPool[rand.Intn(len(photoPool))])
}
return out
}
// genOnePostContent 生成一条文案 + 标签;AI 不可用/失败回退内置文案
func (s *Service) genOnePostContent() (string, string) {
tags := []string{"晒宠", "求助", "经验", "避坑"}
tag := tags[rand.Intn(len(tags))]
if !s.ai.Enabled() {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
topic := botTopics[rand.Intn(len(botTopics))]
system := aiSafetyPrompt + `
你在为宠物社区「宠友圈」生成一条真实、口语化的养宠动态,像普通铲屎官发的朋友圈。
要求:40-120 字;真实有细节,可带 1-2 个 emoji;涉及异常要顺带提醒何时就医;不要标题、不要话题标签、不要营销口吻。
只输出 JSON{"content":"","tag":"晒宠|求助|经验|避坑"}`
user := fmt.Sprintf("主题:%s。请生成一条。", topic)
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
var r struct {
Content string `json:"content"`
Tag string `json:"tag"`
}
if err := json.Unmarshal([]byte(extractJSON(out)), &r); err != nil || r.Content == "" {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
if r.Tag != "" {
tag = r.Tag
}
return r.Content, tag
}
// RunCommunityBotTick 定时器每次调用:白天窗口内按剩余名额概率投放,天然打散在白天
func (s *Service) RunCommunityBotTick() {
cfg := s.getBotConfig()
if !cfg.Enabled || cfg.DailyCount <= 0 {
return
}
now := time.Now()
h := now.Hour()
if h < cfg.StartHour || h >= cfg.EndHour {
return
}
today := s.countAIPostsToday()
if today >= int64(cfg.DailyCount) {
return
}
remaining := cfg.DailyCount - int(today)
minsLeft := (cfg.EndHour-h)*60 - now.Minute()
ticksLeft := minsLeft / botTickMinutes
if ticksLeft < 1 {
ticksLeft = 1
}
if rand.Float64() <= float64(remaining)/float64(ticksLeft) {
_, _ = s.GenerateCommunityPosts(1)
}
}
// StartCommunityBot 启动后台定时投放协程
func (s *Service) StartCommunityBot() {
go func() {
for {
s.RunCommunityBotTick()
time.Sleep(botTickMinutes * time.Minute)
}
}()
}
+88
View File
@@ -0,0 +1,88 @@
package service
import (
"bytes"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"path"
"strings"
"github.com/sundynix/pets-be/internal/model"
)
// 允许的图片扩展名(服务端兜底校验,前端已限制仅图片)
var imageExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".webp": true, ".bmp": true, ".heic": true, ".heif": true,
}
// UploadFile 上传文件并按 MD5 去重:内容相同直接返回已存记录,不重复占用 MinIO 空间。
func (s *Service) UploadFile(reader io.Reader, filename, contentType string) (*model.File, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
sum := md5.Sum(data)
md5hex := hex.EncodeToString(sum[:])
// 已有相同内容 → 直接复用
var existing model.File
if err := s.db.Where("md5 = ?", md5hex).First(&existing).Error; err == nil {
return &existing, nil
}
ext := strings.ToLower(path.Ext(filename))
if !imageExts[ext] && !strings.HasPrefix(contentType, "image/") {
return nil, errors.New("只允许上传图片文件")
}
if contentType == "" {
contentType = "application/octet-stream"
}
objectName := "files/" + md5hex + ext
url, err := s.storage.Upload(objectName, bytes.NewReader(data), int64(len(data)), contentType)
if err != nil {
return nil, err
}
f := model.File{
MD5: md5hex, ObjectName: objectName, URL: url,
Size: int64(len(data)), ContentType: contentType, Ext: ext,
}
if err := s.db.Create(&f).Error; err != nil {
return nil, err
}
return &f, nil
}
// fileURL 按 file_id 取 URL(空 id 或不存在返回空串)
func (s *Service) fileURL(id string) string {
if id == "" {
return ""
}
var f model.File
if err := s.db.Select("url").First(&f, "id = ?", id).Error; err != nil {
return ""
}
return f.URL
}
// fileURLs 批量按 file_id 取 URL
func (s *Service) fileURLs(ids []string) map[string]string {
out := map[string]string{}
clean := make([]string, 0, len(ids))
for _, id := range ids {
if id != "" {
clean = append(clean, id)
}
}
if len(clean) == 0 {
return out
}
var files []model.File
s.db.Where("id IN ?", clean).Find(&files)
for _, f := range files {
out[f.ID] = f.URL
}
return out
}
+14 -8
View File
@@ -19,6 +19,7 @@ type HomeInsight struct {
type WeekDay struct {
Weekday string `json:"weekday"` // 一二三四五六日
Day int `json:"day"`
Date string `json:"date"` // YYYY-MM-DD,供首页点击查当日任务
Active bool `json:"active"` // 是否今天
HasDot bool `json:"has_dot"` // 当天有任务/记录/提醒
}
@@ -36,7 +37,7 @@ type HomeSummary struct {
var weekdayCN = []string{"日", "一", "二", "三", "四", "五", "六"}
// GetHomeSummary 计算首页汇总
func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
func (s *Service) GetHomeSummary(userID, petID string) (*HomeSummary, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
@@ -54,7 +55,7 @@ func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
return res, nil
}
func (s *Service) userNickname(userID uint) string {
func (s *Service) userNickname(userID string) string {
var u model.User
if err := s.db.Select("nickname").First(&u, userID).Error; err == nil && u.Nickname != "" {
return u.Nickname
@@ -76,7 +77,7 @@ func greeting(t time.Time, name string) string {
}
}
func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
func (s *Service) homeInsights(petID string, now time.Time) []HomeInsight {
insights := make([]HomeInsight, 0, 3)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
@@ -112,7 +113,7 @@ func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
}
// streakDays 从今天往前,连续有健康记录的天数
func (s *Service) streakDays(petID uint, now time.Time) int {
func (s *Service) streakDays(petID string, 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++ {
@@ -129,7 +130,7 @@ func (s *Service) streakDays(petID uint, now time.Time) int {
return streak
}
func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
func (s *Service) homeWeek(petID string, 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
@@ -143,6 +144,7 @@ func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
week[i] = WeekDay{
Weekday: weekdayCN[int(d.Weekday())],
Day: d.Day(),
Date: d.Format("2006-01-02"),
Active: d.Equal(today),
HasDot: dotSet[d.Format("2006-01-02")],
}
@@ -151,7 +153,7 @@ func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
}
// datesWithActivity 区间内有任务/记录/提醒的日期集合
func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string]bool {
func (s *Service) datesWithActivity(petID string, 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)
@@ -170,10 +172,14 @@ func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string
set[r.NextDueDate.Format("2006-01-02")] = true
}
}
// 30 天计划路线图节点也算「有活动」,让首页周历与计划日历一致
for _, d := range s.planNodeDates(petID, start, end) {
set[d.Format("2006-01-02")] = true
}
return set
}
func (s *Service) todayCompletionPct(petID uint, now time.Time) int {
func (s *Service) todayCompletionPct(petID string, 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
@@ -214,7 +220,7 @@ type UserSummary struct {
}
// GetUserSummary 汇总当前用户名下计数
func (s *Service) GetUserSummary(userID uint) (*UserSummary, error) {
func (s *Service) GetUserSummary(userID string) (*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)
+63 -35
View File
@@ -20,9 +20,10 @@ type PetInput struct {
Weight string
Stage string
Age string
Color string
Breed string
Goals datatypes.JSON
Color string
Breed string
AvatarFileID string
Goals datatypes.JSON
}
func normalizeWeight(w string) string {
@@ -37,19 +38,31 @@ func normalizeWeight(w string) string {
}
// ListPets 用户的全部宠物
func (s *Service) ListPets(userID uint) ([]model.Pet, error) {
func (s *Service) ListPets(userID string) ([]model.Pet, error) {
var pets []model.Pet
err := s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets).Error
ids := make([]string, 0, len(pets))
for i := range pets {
ids = append(ids, pets[i].AvatarFileID)
}
urls := s.fileURLs(ids)
for i := range pets {
pets[i].AvatarURL = urls[pets[i].AvatarFileID]
}
return pets, err
}
// GetPet 取单只宠物(校验归属)
func (s *Service) GetPet(userID, petID uint) (*model.Pet, error) {
return s.ownedPet(userID, petID)
func (s *Service) GetPet(userID, petID string) (*model.Pet, error) {
p, err := s.ownedPet(userID, petID)
if err == nil && p != nil {
p.AvatarURL = s.fileURL(p.AvatarFileID)
}
return p, err
}
// CreatePet 新增宠物并生成默认数据
func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
pet := model.Pet{
UserID: userID,
Name: in.Name,
@@ -62,6 +75,7 @@ func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
Age: in.Age,
Color: in.Color,
Breed: in.Breed,
AvatarFileID: in.AvatarFileID,
HealthStatus: "正常",
Goals: in.Goals,
}
@@ -77,7 +91,7 @@ func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
}
// UpdatePet 更新宠物字段
func (s *Service) UpdatePet(userID, petID uint, fields map[string]any) (*model.Pet, error) {
func (s *Service) UpdatePet(userID, petID string, fields map[string]any) (*model.Pet, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -91,13 +105,13 @@ func (s *Service) UpdatePet(userID, petID uint, fields map[string]any) (*model.P
}
// DeletePet 删除宠物并级联清除其记录/任务/计划/提醒/每日建议(不留孤儿数据)
func (s *Service) DeletePet(userID, petID uint) error {
func (s *Service) DeletePet(userID, petID string) 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
var planIDs []string
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 {
@@ -117,7 +131,7 @@ func (s *Service) DeletePet(userID, petID uint) error {
}
// Onboarding 建首宠 + 标记用户已引导
func (s *Service) Onboarding(userID uint, in PetInput) (*model.Pet, error) {
func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
pet, err := s.CreatePet(userID, in)
if err != nil {
return nil, err
@@ -128,41 +142,55 @@ func (s *Service) Onboarding(userID uint, in PetInput) (*model.Pet, error) {
return pet, nil
}
// seedPetDefaults 为新宠生成默认今日任务、提醒、30 天计划
// 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
items := s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage)
var tasks []model.DailyTask
var reminders []model.Reminder
var planTasks []model.PlanTask
for _, it := range items {
switch it.Kind {
case model.CareKindTask:
tasks = append(tasks, model.DailyTask{
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
Title: it.Title, Description: it.Description, Priority: it.Priority, SheetType: it.SheetType,
})
case model.CareKindPlan:
planTasks = append(planTasks, model.PlanTask{
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
})
case model.CareKindReminder:
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
due := today.AddDate(0, 0, it.OffsetDays)
r.NextDueDate = &due
}
reminders = append(reminders, r)
}
}
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 len(tasks) > 0 {
if err := tx.Create(&tasks).Error; err != nil {
return err
}
}
if err := tx.Create(&reminders).Error; err != nil {
return err
if len(reminders) > 0 {
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"},
},
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
Tasks: planTasks,
}
return tx.Create(&plan).Error
}
+75 -25
View File
@@ -12,7 +12,7 @@ import (
)
// TogglePlanTask 切换计划明细完成状态(校验归属)
func (s *Service) TogglePlanTask(userID, taskID uint) error {
func (s *Service) TogglePlanTask(userID, taskID string) error {
var pt model.PlanTask
if err := s.db.First(&pt, taskID).Error; err != nil {
return ErrNotFound
@@ -25,7 +25,7 @@ func (s *Service) TogglePlanTask(userID, taskID uint) error {
}
// GetPlan 取宠物的 30 天计划(含明细)
func (s *Service) GetPlan(userID, petID uint) (*model.Plan, error) {
func (s *Service) GetPlan(userID, petID string) (*model.Plan, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -51,9 +51,34 @@ func (s *Service) GetPlan(userID, petID uint) (*model.Plan, error) {
} else {
plan.CompletionPct = 0
}
// 计算每个路线图节点的真实日期(开始日 + Day),供前端展示并与日历对齐
if plan.StartDate != nil {
for i := range plan.Tasks {
plan.Tasks[i].Date = plan.StartDate.AddDate(0, 0, plan.Tasks[i].Day).Format("2006-01-02")
}
}
return &plan, nil
}
// planNodeDates 30 天计划路线图节点在 [start,end) 内的真实日期(开始日 + Day)
func (s *Service) planNodeDates(petID string, start, end time.Time) []time.Time {
var plan model.Plan
if err := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; err != nil || plan.StartDate == nil {
return nil
}
var out []time.Time
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
if !dd.Before(start) && dd.Before(end) {
out = append(out, dd)
}
}
return out
}
// CalendarResult 日历视图
type CalendarResult struct {
Year int `json:"year"`
@@ -63,7 +88,7 @@ type CalendarResult struct {
}
// Calendar 某月有任务/提醒的日期
func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult, error) {
func (s *Service) Calendar(userID, petID string, year, month int) (*CalendarResult, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -71,29 +96,15 @@ func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult
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{}{}
// 与首页周历同源:任务/记录/提醒/计划路线图节点
set := s.datesWithActivity(petID, start, end)
days := make([]int, 0, len(set))
for k := range set {
if t, err := time.Parse("2006-01-02", k); err == nil {
days = append(days, t.Day())
}
}
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 {
@@ -102,8 +113,47 @@ func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult
return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil
}
// DayPlan 某一天的安排:计划路线图节点 + 今日任务 + 到期提醒
func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
day, err := time.ParseInLocation("2006-01-02", dateStr, time.Now().Location())
if err != nil {
return nil, ErrNotFound
}
next := day.AddDate(0, 0, 1)
nodes := []model.PlanTask{}
var plan model.Plan
if e := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; e == nil && plan.StartDate != nil {
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
if d.Year() == day.Year() && d.YearDay() == day.YearDay() {
t.Date = d.Format("2006-01-02")
nodes = append(nodes, t)
}
}
}
var tasks []model.DailyTask
s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, day, next).Find(&tasks)
var reminders []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, day, next).Find(&reminders)
return map[string]any{
"date": dateStr,
"plan_nodes": nodes,
"tasks": tasks,
"reminders": reminders,
}, nil
}
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
func (s *Service) CreateAIPlan(userID, petID uint, input string) (*model.Plan, error) {
func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
@@ -140,7 +190,7 @@ func (s *Service) CreateAIPlan(userID, petID uint, input string) (*model.Plan, e
}
// ApplyAIPlan 确认并应用 AI 计划
func (s *Service) ApplyAIPlan(userID, planID uint) (*model.Plan, error) {
func (s *Service) ApplyAIPlan(userID, planID string) (*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 {
+2 -2
View File
@@ -27,7 +27,7 @@ type ProInfo struct {
}
// GetPro 取会员信息
func (s *Service) GetPro(userID uint) (*ProInfo, error) {
func (s *Service) GetPro(userID string) (*ProInfo, error) {
var m model.ProMembership
err := s.db.Where("user_id = ?", userID).First(&m).Error
if err == gorm.ErrRecordNotFound {
@@ -40,7 +40,7 @@ func (s *Service) GetPro(userID uint) (*ProInfo, error) {
}
// ActivatePro 开通年费会员
func (s *Service) ActivatePro(userID uint) (*ProInfo, error) {
func (s *Service) ActivatePro(userID string) (*ProInfo, error) {
now := time.Now()
end := now.AddDate(1, 0, 0)
var m model.ProMembership
+35 -10
View File
@@ -19,26 +19,46 @@ type RecordInput struct {
NumValue float64
Category string
ImageURL string
ImageFileID string
Extra datatypes.JSON
OccurredAt *time.Time
}
// ListRecords 列出宠物的健康记录(可按 type 过滤)
func (s *Service) ListRecords(userID, petID uint, recordType string) ([]model.HealthRecord, error) {
func (s *Service) ListRecords(userID, petID string, recordType string, offset, limit int) ([]model.HealthRecord, int64, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
return nil, 0, err
}
q := s.db.Where("pet_id = ?", petID)
q := s.db.Model(&model.HealthRecord{}).Where("pet_id = ?", petID)
if recordType != "" {
q = q.Where("type = ?", recordType)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var records []model.HealthRecord
err := q.Order("occurred_at desc, id desc").Find(&records).Error
return records, err
err := q.Order("occurred_at desc, id desc").Offset(offset).Limit(limit).Find(&records).Error
// 用 file_id 回填图片 URL
ids := make([]string, 0)
for i := range records {
if records[i].ImageFileID != "" && records[i].ImageURL == "" {
ids = append(ids, records[i].ImageFileID)
}
}
if len(ids) > 0 {
urls := s.fileURLs(ids)
for i := range records {
if records[i].ImageURL == "" {
records[i].ImageURL = urls[records[i].ImageFileID]
}
}
}
return records, total, err
}
// CreateRecord 新增健康记录;weight 类型同步更新宠物体重
func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.HealthRecord, error) {
func (s *Service) CreateRecord(userID, petID string, in RecordInput) (*model.HealthRecord, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
@@ -50,7 +70,11 @@ func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.Healt
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,
Category: in.Category, ImageURL: in.ImageURL, ImageFileID: in.ImageFileID, Extra: in.Extra, OccurredAt: occurred,
}
// 传了 file_id 未传 url 时,用 file 表的 url 回填 ImageURL 便于直接展示
if in.ImageFileID != "" && rec.ImageURL == "" {
rec.ImageURL = s.fileURL(in.ImageFileID)
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&rec).Error; err != nil {
@@ -70,7 +94,7 @@ func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.Healt
}
// DeleteRecord 删除记录
func (s *Service) DeleteRecord(userID, recordID uint) error {
func (s *Service) DeleteRecord(userID, recordID string) error {
res := s.db.Where("id = ? AND user_id = ?", recordID, userID).Delete(&model.HealthRecord{})
if res.Error != nil {
return res.Error
@@ -85,10 +109,11 @@ func (s *Service) DeleteRecord(userID, recordID uint) error {
type WeightPoint struct {
Value float64 `json:"value"`
OccurredAt time.Time `json:"occurred_at"`
Note string `json:"note"` // 记录时填的备注
}
// WeightTrend 最近 N 次体重(升序)
func (s *Service) WeightTrend(userID, petID uint, limit int) ([]WeightPoint, error) {
func (s *Service) WeightTrend(userID, petID string, limit int) ([]WeightPoint, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -102,7 +127,7 @@ func (s *Service) WeightTrend(userID, petID uint, limit int) ([]WeightPoint, 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})
points = append(points, WeightPoint{Value: records[i].NumValue, OccurredAt: records[i].OccurredAt, Note: records[i].Description})
}
return points, nil
}
+4 -4
View File
@@ -15,7 +15,7 @@ type ReminderInput struct {
}
// ListReminders 宠物的提醒列表
func (s *Service) ListReminders(userID, petID uint) ([]model.Reminder, error) {
func (s *Service) ListReminders(userID, petID string) ([]model.Reminder, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (s *Service) ListReminders(userID, petID uint) ([]model.Reminder, error) {
}
// CreateReminder 新增提醒
func (s *Service) CreateReminder(userID, petID uint, in ReminderInput) (*model.Reminder, error) {
func (s *Service) CreateReminder(userID, petID string, in ReminderInput) (*model.Reminder, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -40,7 +40,7 @@ func (s *Service) CreateReminder(userID, petID uint, in ReminderInput) (*model.R
}
// UpdateReminder 更新提醒
func (s *Service) UpdateReminder(userID, reminderID uint, fields map[string]any) (*model.Reminder, error) {
func (s *Service) UpdateReminder(userID, reminderID string, 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
@@ -52,7 +52,7 @@ func (s *Service) UpdateReminder(userID, reminderID uint, fields map[string]any)
}
// DeleteReminder 删除提醒
func (s *Service) DeleteReminder(userID, reminderID uint) error {
func (s *Service) DeleteReminder(userID, reminderID string) error {
res := s.db.Where("id = ? AND user_id = ?", reminderID, userID).Delete(&model.Reminder{})
if res.Error != nil {
return res.Error
+4 -4
View File
@@ -18,7 +18,7 @@ type WeeklyReport struct {
}
// GetWeeklyReport 计算最近 7 天周报
func (s *Service) GetWeeklyReport(userID, petID uint) (*WeeklyReport, error) {
func (s *Service) GetWeeklyReport(userID, petID string) (*WeeklyReport, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
@@ -76,7 +76,7 @@ type Bill struct {
}
// GetBill 账单(period=month 取本月)
func (s *Service) GetBill(userID, petID uint, period string) (*Bill, error) {
func (s *Service) GetBill(userID, petID string, period string) (*Bill, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -129,7 +129,7 @@ type HealthSummary struct {
}
// GetHealthSummary 健康摘要聚合
func (s *Service) GetHealthSummary(userID, petID uint) (*HealthSummary, error) {
func (s *Service) GetHealthSummary(userID, petID string) (*HealthSummary, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -168,7 +168,7 @@ type Poster struct {
}
// GetPoster 生成海报聚合数据
func (s *Service) GetPoster(userID, petID uint) (*Poster, error) {
func (s *Service) GetPoster(userID, petID string) (*Poster, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
+1 -1
View File
@@ -27,7 +27,7 @@ func New(db *gorm.DB, st *storage.Storage, cfg *config.Config, engine *ai.Engine
}
// ownedPet 校验宠物归属当前用户并返回
func (s *Service) ownedPet(userID, petID uint) (*model.Pet, error) {
func (s *Service) ownedPet(userID, petID string) (*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) {
+3 -3
View File
@@ -9,7 +9,7 @@ import (
)
// ListTasks 列出宠物任务(date 为空则取今天)
func (s *Service) ListTasks(userID, petID uint, date *time.Time) ([]model.DailyTask, error) {
func (s *Service) ListTasks(userID, petID string, date *time.Time) ([]model.DailyTask, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (s *Service) ListTasks(userID, petID uint, date *time.Time) ([]model.DailyT
}
// ToggleTask 切换任务完成状态
func (s *Service) ToggleTask(userID, taskID uint) (*model.DailyTask, error) {
func (s *Service) ToggleTask(userID, taskID string) (*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 {
@@ -47,7 +47,7 @@ func (s *Service) ToggleTask(userID, taskID uint) (*model.DailyTask, error) {
}
// CompleteAllTasks 完成宠物今日全部任务
func (s *Service) CompleteAllTasks(userID, petID uint) ([]model.DailyTask, error) {
func (s *Service) CompleteAllTasks(userID, petID string) ([]model.DailyTask, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
+52
View File
@@ -0,0 +1,52 @@
// Package idgen 生成雪花算法 ID(字符串形式),用作所有表的主键。
// 结构:41 位毫秒时间戳 + 10 位节点 + 12 位序列,单机单调递增、趋势有序。
package idgen
import (
"strconv"
"sync"
"time"
)
const (
epoch int64 = 1704067200000 // 2024-01-01 UTC,毫秒
nodeBits uint = 10
stepBits uint = 12
stepMask int64 = -1 ^ (-1 << stepBits)
timeShift uint = nodeBits + stepBits
nodeShift uint = stepBits
)
var (
mu sync.Mutex
node int64 = 1
lastTime int64 = -1
step int64
)
// SetNode 设置节点号(0-1023);多实例部署时各实例用不同值避免冲突。
func SetNode(n int64) {
mu.Lock()
defer mu.Unlock()
node = n & (-1 ^ (-1 << nodeBits))
}
// Next 返回下一个雪花 ID 的字符串形式。
func Next() string {
mu.Lock()
defer mu.Unlock()
now := time.Now().UnixMilli()
if now == lastTime {
step = (step + 1) & stepMask
if step == 0 { // 同一毫秒序列用尽,等到下一毫秒
for now <= lastTime {
now = time.Now().UnixMilli()
}
}
} else {
step = 0
}
lastTime = now
id := ((now - epoch) << timeShift) | (node << nodeShift) | step
return strconv.FormatInt(id, 10)
}
+2 -2
View File
@@ -17,7 +17,7 @@ const (
// Claims 自定义声明,user 与 admin 复用同一结构,用 Kind 区分
type Claims struct {
ID uint `json:"id"`
ID string `json:"id"`
Kind Kind `json:"kind"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
@@ -38,7 +38,7 @@ func NewManager(secret string, expireHours int) *Manager {
}
// Generate 签发令牌
func (m *Manager) Generate(id uint, kind Kind, name, role string) (string, error) {
func (m *Manager) Generate(id string, kind Kind, name, role string) (string, error) {
now := time.Now()
claims := Claims{
ID: id,
+4
View File
@@ -9,6 +9,8 @@ import Pets from '@/pages/Pets'
import Posts from '@/pages/Posts'
import Comments from '@/pages/Comments'
import Articles from '@/pages/Articles'
import CareTemplates from '@/pages/CareTemplates'
import CommunityBot from '@/pages/CommunityBot'
import Members from '@/pages/Members'
function RequireAuth({ children }: { children: JSX.Element }) {
@@ -33,6 +35,8 @@ export default function App() {
<Route path="posts" element={<Posts />} />
<Route path="comments" element={<Comments />} />
<Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} />
<Route path="community-bot" element={<CommunityBot />} />
<Route path="members" element={<Members />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+3 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, Crown, LogOut } from 'lucide-react'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, Crown, LogOut } from 'lucide-react'
import { clearToken } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -11,6 +11,8 @@ const nav = [
{ to: '/posts', label: '帖子审核', icon: MessageSquare },
{ to: '/comments', label: '评论管理', icon: MessagesSquare },
{ to: '/articles', label: '文章管理', icon: FileText },
{ to: '/care-templates', label: '养护模板', icon: ClipboardList },
{ to: '/community-bot', label: '社区运营', icon: Sparkles },
{ to: '/members', label: '会员管理', icon: Crown },
]
+21
View File
@@ -67,6 +67,27 @@ export const api = {
deleteArticle: (id: number) => http.delete(`/admin/articles/${id}`),
memberships: (params: any) => http.get<any, Page<any>>('/admin/memberships', { params }),
careTemplateMeta: () => http.get<any, any>('/admin/care-templates/meta'),
careTemplate: (species: string, stage: string) =>
http.get<any, { species: string; stage: string; items: any[] }>('/admin/care-templates', {
params: { species, stage },
}),
saveCareTemplate: (species: string, stage: string, items: any[]) =>
http.put<any, { items: any[] }>('/admin/care-templates', { species, stage, items }),
genCareTemplate: (species: string, stage: string) =>
http.post<any, { species: string; stage: string; items: any[] }>('/admin/care-templates/generate', {
species,
stage,
}),
communityBot: () =>
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
'/admin/community-bot',
),
saveCommunityBot: (c: any) => http.put<any, any>('/admin/community-bot', c),
genCommunityPosts: (count: number) =>
http.post<any, { made: number }>('/admin/community-bot/generate', { count }),
}
export default http
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Cat, Dog, Wand2, Plus, Trash2, Save, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
const SPECIES = [
{ key: 'cat', label: '猫猫', Icon: Cat },
{ key: 'dog', label: '狗狗', Icon: Dog },
]
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']
const SHEET_LABEL: Record<string, string> = {
'': '无', weight: '体重', poop: '便便', food: '饮食', symptom: '异常',
vaccine: '疫苗', medicine: '用药', report: '报告', taskDetail: '任务详情',
}
const REMINDER_LABEL: Record<string, string> = {
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报',
}
const TASK_SHEETS = ['', 'weight', 'poop', 'food', 'symptom', 'vaccine', 'medicine']
const PLAN_SHEETS = ['', 'weight', 'vaccine', 'medicine', 'report', 'taskDetail']
const REMINDER_TYPES = ['vaccine', 'deworm', 'weight', 'monthlyReport']
const selectCls =
'h-9 rounded-md border border-input bg-card px-2 text-sm shrink-0'
type Item = {
_k: number
kind: 'task' | 'plan' | 'reminder'
title: string
description: string
priority: string
sheet_type: string
day: number
day_label: string
reminder_type: string
offset_days: number
frequency: string
}
let uid = 1
function normalize(raw: any[]): Item[] {
return (raw || []).map((r) => ({
_k: uid++,
kind: r.kind,
title: r.title || '',
description: r.description || '',
priority: r.priority || '',
sheet_type: r.sheet_type || '',
day: r.day ?? 0,
day_label: r.day_label || '',
reminder_type: r.reminder_type || 'vaccine',
offset_days: r.offset_days ?? 0,
frequency: r.frequency || '',
}))
}
function blank(kind: Item['kind']): Item {
return {
_k: uid++, kind, title: '', description: '', priority: '', sheet_type: '',
day: 0, day_label: '', reminder_type: 'vaccine', offset_days: 0, frequency: '',
}
}
export default function CareTemplates() {
const [species, setSpecies] = useState('cat')
const [stage, setStage] = useState('成年期')
const [items, setItems] = useState<Item[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [gen, setGen] = useState(false)
const [dirty, setDirty] = useState(false)
const [msg, setMsg] = useState('')
const reqId = useRef(0)
async function load(sp: string, st: string) {
const my = ++reqId.current
setLoading(true)
setMsg('')
try {
const data = await api.careTemplate(sp, st)
if (my === reqId.current) {
setItems(normalize(data.items))
setDirty(false)
}
} finally {
if (my === reqId.current) setLoading(false)
}
}
useEffect(() => {
load(species, stage)
}, [species, stage])
function update(k: number, key: keyof Item, value: any) {
setItems((prev) => prev.map((it) => (it._k === k ? { ...it, [key]: value } : it)))
setDirty(true)
}
function remove(k: number) {
setItems((prev) => prev.filter((it) => it._k !== k))
setDirty(true)
}
function add(kind: Item['kind']) {
setItems((prev) => [...prev, blank(kind)])
setDirty(true)
}
async function generate() {
setGen(true)
setMsg('')
try {
const data = await api.genCareTemplate(species, stage)
setItems(normalize(data.items))
setDirty(true)
setMsg('已生成草稿,确认无误后点「保存该模板」')
} catch (e: any) {
setMsg(e?.message || 'AI 生成失败')
} finally {
setGen(false)
}
}
async function save() {
setSaving(true)
setMsg('')
try {
await api.saveCareTemplate(species, stage, items)
setDirty(false)
setMsg('已保存')
} catch (e: any) {
setMsg(e?.message || '保存失败')
} finally {
setSaving(false)
}
}
const tasks = useMemo(() => items.filter((i) => i.kind === 'task'), [items])
const plans = useMemo(() => items.filter((i) => i.kind === 'plan'), [items])
const reminders = useMemo(() => items.filter((i) => i.kind === 'reminder'), [items])
return (
<div>
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
/
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={generate} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : 'AI 生成草稿'}
</Button>
<Button onClick={save} disabled={saving || !dirty}>
<Save className="h-4 w-4" /> {saving ? '保存中…' : '保存该模板'}
</Button>
</div>
</div>
<div className="flex gap-2 mb-2">
{SPECIES.map((s) => (
<button
key={s.key}
onClick={() => setSpecies(s.key)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors',
species === s.key ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
)}
>
<s.Icon className="h-4 w-4" /> {s.label}
</button>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
{STAGES.map((st) => (
<button
key={st}
onClick={() => setStage(st)}
className={cn(
'rounded-full border px-3 py-1.5 text-sm transition-colors',
stage === st ? 'bg-primary/15 text-primary border-primary/40' : 'text-muted-foreground hover:bg-accent',
)}
>
{st}
</button>
))}
</div>
{msg && (
<div className="mb-4 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-sm text-primary">
<Info className="h-4 w-4" /> {msg}
</div>
)}
{loading ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
<Section title="今日任务" count={tasks.length} onAdd={() => add('task')}>
{tasks.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input className="flex-1 min-w-40" placeholder="任务标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<Input className="flex-1 min-w-40" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
<select className={selectCls} value={it.priority} onChange={(e) => update(it._k, 'priority', e.target.value)}>
<option value=""></option>
<option value="重要"></option>
</select>
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
{TASK_SHEETS.map((s) => <option key={s} value={s}>·{SHEET_LABEL[s]}</option>)}
</select>
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<Section title="30 天计划" count={plans.length} onAdd={() => add('plan')}>
{plans.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<Input type="number" className="w-16" placeholder="第N天" value={it.day} onChange={(e) => update(it._k, 'day', Number(e.target.value))} />
<Input className="w-24" placeholder="标签" value={it.day_label} onChange={(e) => update(it._k, 'day_label', e.target.value)} />
<Input className="flex-1 min-w-36" placeholder="节点标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<Input className="flex-1 min-w-36" placeholder="说明" value={it.description} onChange={(e) => update(it._k, 'description', e.target.value)} />
<select className={selectCls} value={it.sheet_type} onChange={(e) => update(it._k, 'sheet_type', e.target.value)}>
{PLAN_SHEETS.map((s) => <option key={s} value={s}>·{SHEET_LABEL[s]}</option>)}
</select>
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<Section title="提醒" count={reminders.length} onAdd={() => add('reminder')}>
{reminders.map((it) => (
<div key={it._k} className="flex flex-wrap items-center gap-2">
<select className={selectCls} value={it.reminder_type} onChange={(e) => update(it._k, 'reminder_type', e.target.value)}>
{REMINDER_TYPES.map((s) => <option key={s} value={s}>{REMINDER_LABEL[s]}</option>)}
</select>
<Input className="flex-1 min-w-40" placeholder="提醒标题" value={it.title} onChange={(e) => update(it._k, 'title', e.target.value)} />
<div className="flex items-center gap-1 shrink-0">
<span className="text-sm text-muted-foreground">+</span>
<Input type="number" className="w-16" value={it.offset_days} onChange={(e) => update(it._k, 'offset_days', Number(e.target.value))} />
<span className="text-sm text-muted-foreground"></span>
</div>
<Input className="flex-1 min-w-36" placeholder="或填频率(到期天数填0)" value={it.frequency} onChange={(e) => update(it._k, 'frequency', e.target.value)} />
<DelBtn onClick={() => remove(it._k)} />
</div>
))}
</Section>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5" /> 退
</p>
</div>
)}
</div>
)
}
function Section({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
return (
<Card>
<CardContent className="pt-4 pb-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium">
{title} <span className="text-muted-foreground">{count}</span>
</div>
<Button variant="outline" size="sm" onClick={onAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{count === 0 ? <p className="text-sm text-muted-foreground"></p> : children}
</div>
</CardContent>
</Card>
)
}
function DelBtn({ onClick }: { onClick: () => void }) {
return (
<Button variant="ghost" size="icon" className="text-muted-foreground shrink-0" onClick={onClick} aria-label="删除">
<Trash2 className="h-4 w-4" />
</Button>
)
}
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react'
import { Sparkles, Save, Wand2, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
type Cfg = { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }
export default function CommunityBot() {
const [cfg, setCfg] = useState<Cfg>({ enabled: true, daily_count: 3, start_hour: 9, end_hour: 21 })
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [genN, setGenN] = useState(5)
const [gen, setGen] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
api.communityBot().then((c) => {
setCfg(c)
setLoading(false)
})
}, [])
const num = (k: keyof Cfg, min: number, max: number) => (e: any) => {
let v = Number(e.target.value)
if (Number.isNaN(v)) v = min
v = Math.max(min, Math.min(max, v))
setCfg({ ...cfg, [k]: v })
}
async function save() {
setSaving(true)
setMsg('')
try {
const saved = await api.saveCommunityBot(cfg)
setCfg(saved)
setMsg('已保存')
} catch (e: any) {
setMsg(e?.message || '保存失败')
} finally {
setSaving(false)
}
}
async function generate() {
setGen(true)
setMsg('')
try {
const { made } = await api.genCommunityPosts(genN)
setMsg(`已生成 ${made} 条并发布到宠友圈`)
} catch (e: any) {
setMsg(e?.message || '生成失败')
} finally {
setGen(false)
}
}
return (
<div className="max-w-2xl">
<div className="mb-4">
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
AI AI
</p>
</div>
{msg && (
<div className="mb-4 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-sm text-primary">
<Info className="h-4 w-4" /> {msg}
</div>
)}
{loading ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
<Card>
<CardContent className="pt-5 pb-5 space-y-4">
<div className="flex items-center gap-2 font-medium">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label></Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-card px-3 text-sm"
value={cfg.enabled ? '1' : '0'}
onChange={(e) => setCfg({ ...cfg, enabled: e.target.value === '1' })}
>
<option value="1"></option>
<option value="0"></option>
</select>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input type="number" value={cfg.daily_count} onChange={num('daily_count', 0, 50)} />
</div>
<div className="space-y-1.5">
<Label></Label>
<Input type="number" value={cfg.start_hour} onChange={num('start_hour', 0, 23)} />
</div>
<div className="space-y-1.5">
<Label></Label>
<Input type="number" value={cfg.end_hour} onChange={num('end_hour', 1, 24)} />
</div>
</div>
<p className="text-xs text-muted-foreground">
{cfg.enabled ? '开启' : '关闭'} {cfg.daily_count}
{cfg.start_hour}:00 {cfg.end_hour}:00
</p>
<div>
<Button onClick={save} disabled={saving}>
<Save className="h-4 w-4" /> {saving ? '保存中…' : '保存设置'}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-5 space-y-4">
<div className="flex items-center gap-2 font-medium">
<Wand2 className="h-4 w-4 text-primary" />
</div>
<p className="text-sm text-muted-foreground">
AI 30
</p>
<div className="flex items-end gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
className="w-28"
value={genN}
onChange={(e) => setGenN(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
/>
</div>
<Button variant="outline" onClick={generate} disabled={gen}>
<Wand2 className="h-4 w-4" /> {gen ? '生成中…' : `立即生成 ${genN}`}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}
+19 -2
View File
@@ -11,7 +11,8 @@ import { Pager } from '@/components/Pager'
export default function Users() {
const [keyword, setKeyword] = useState('')
const [query, setQuery] = useState('')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.users, { keyword: query })
const [type, setType] = useState('')
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.users, { keyword: query, type })
async function toggle(u: any) {
await api.setUserDisabled(u.id, !u.disabled)
@@ -30,6 +31,15 @@ export default function Users() {
onKeyDown={(e) => e.key === 'Enter' && (setPage(1), setQuery(keyword))}
/>
<Button onClick={() => (setPage(1), setQuery(keyword))}></Button>
<select
className="h-9 rounded-md border border-input bg-card px-3 text-sm"
value={type}
onChange={(e) => (setPage(1), setType(e.target.value))}
>
<option value=""></option>
<option value="real"></option>
<option value="bot">(AI)</option>
</select>
</div>
<Card>
<CardContent className="pt-6">
@@ -48,7 +58,14 @@ export default function Users() {
{data?.list?.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.id}</TableCell>
<TableCell className="font-medium">{u.nickname}</TableCell>
<TableCell className="font-medium">
{u.nickname}
{u.is_bot && (
<Badge variant="muted" className="ml-2">
AI
</Badge>
)}
</TableCell>
<TableCell>{u.phone || '—'}</TableCell>
<TableCell>{u.onboarded ? '已建档' : '未建档'}</TableCell>
<TableCell>