feat: 记录删除与筛选、用户侧内容删除、AI 聊天历史

修复雪花 ID 精度丢失(真 bug)
- bottom-sheet 的 postId 声明为 Number,而 ID 是 18 位雪花字符串,
  超出 JS 安全整数范围:339346584095428608 会被转成 ...600,
  评论弹层实际查的是不存在的帖子。改为 String

记录
- 后端 DELETE /records/:id 早已就绪但前端从未调用,记错了删不掉。
  时间轴支持长按删除
- 时间轴加类型筛选(体重/便便/饮食/异常/用药/疫苗/消费/照片),
  后端 ?type= 本就支持

用户侧内容删除(UGC 合规:用户应能删除自己发布的内容)
- 新增 DELETE /posts/:id 与 DELETE /comments/:id,仅限本人,
  越权返回 40300;帖子软删除,评论删除同步递减 comment_count
- 评论列表返回 is_self;社区长按自己的帖子、长按自己的评论可删

AI 聊天历史
- ai_messages 一直在写但没有查询接口,每次打开弹层都是空白。
  新增 GET /api/ai/messages,弹层打开时接在开场白后加载

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-28 17:42:14 +08:00
parent 77beb9ca96
commit 2e3345cdfc
17 changed files with 240 additions and 14 deletions
+19 -1
View File
@@ -104,7 +104,7 @@ func (h *Handler) ListComments(c *gin.Context) {
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListComments(idParam(c, "id"), req.Offset(), req.Limit())
comments, total, err := h.svc.ListComments(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
@@ -152,3 +152,21 @@ func (h *Handler) UnfollowUser(c *gin.Context) {
}
response.OK(c, gin.H{"followed": false})
}
// DeleteOwnPost DELETE /api/posts/:id (仅限本人)
func (h *Handler) DeleteOwnPost(c *gin.Context) {
if err := h.svc.DeleteOwnPost(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// DeleteOwnComment DELETE /api/comments/:id (仅限本人)
func (h *Handler) DeleteOwnComment(c *gin.Context) {
if err := h.svc.DeleteOwnComment(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
+4
View File
@@ -27,5 +27,9 @@ func respondErr(c *gin.Context, err error) {
response.Fail(c, errcode.ErrNotFound, "")
return
}
if errors.Is(err, service.ErrForbidden) {
response.Fail(c, errcode.ErrForbidden, err.Error())
return
}
response.FailErr(c, err)
}
+15
View File
@@ -2,6 +2,7 @@ package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
@@ -173,3 +174,17 @@ func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
}
response.OK(c, gin.H{"ok": true})
}
// ListAIMessages GET /api/ai/messages?session=&limit=
func (h *Handler) ListAIMessages(c *gin.Context) {
limit := 0
if v := c.Query("limit"); v != "" {
limit, _ = strconv.Atoi(v)
}
msgs, err := h.svc.ListAIMessages(middleware.UserID(c), c.Query("session"), limit)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, msgs)
}
+2
View File
@@ -8,4 +8,6 @@ type Comment struct {
AuthorName string `gorm:"size:64" json:"author_name"`
Content string `gorm:"size:512" json:"content"`
Status string `gorm:"size:16;default:published" json:"status"`
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
}
+3
View File
@@ -94,6 +94,8 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.DELETE("/posts/:id/like", h.UnlikePost)
g.GET("/posts/:id/comments", h.ListComments)
g.POST("/posts/:id/comments", h.CreateComment)
g.DELETE("/posts/:id", h.DeleteOwnPost)
g.DELETE("/comments/:id", h.DeleteOwnComment)
g.GET("/articles", h.ListArticles)
g.POST("/feedback", h.CreateFeedback)
@@ -103,6 +105,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.POST("/pro/activate", h.ActivatePro)
g.POST("/ai/chat", h.AIChat)
g.GET("/ai/messages", h.ListAIMessages)
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
g.POST("/upload", h.Upload)
}
+20
View File
@@ -29,3 +29,23 @@ func (s *Service) AIChat(userID string, petID *string, session, text string) (st
func (s *Service) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
return s.storage.Upload(objectName, reader, size, contentType)
}
// ListAIMessages 取当前用户的聊天历史(按会话,默认最近 N 条,返回时间正序)
func (s *Service) ListAIMessages(userID, session string, limit int) ([]model.AIMessage, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
q := s.db.Where("user_id = ?", userID)
if session != "" {
q = q.Where("session = ?", session)
}
var msgs []model.AIMessage
if err := q.Order("id desc").Limit(limit).Find(&msgs).Error; err != nil {
return nil, err
}
// 反转成时间正序,前端直接按顺序渲染
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
+39 -1
View File
@@ -192,7 +192,7 @@ func (s *Service) postLikeCount(postID string) (int, error) {
}
// ListComments 评论分页
func (s *Service) ListComments(postID string, offset, limit int) ([]model.Comment, int64, error) {
func (s *Service) ListComments(userID, 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 {
@@ -202,6 +202,9 @@ func (s *Service) ListComments(postID string, offset, limit int) ([]model.Commen
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
return nil, 0, err
}
for i := range comments {
comments[i].IsSelf = comments[i].UserID == userID
}
return comments, total, nil
}
@@ -227,3 +230,38 @@ func (s *Service) CreateComment(userID, postID string, content string) (*model.C
}
return &comment, nil
}
// DeleteOwnPost 用户删除自己的帖子(软删除,保留数据可追溯)
func (s *Service) DeleteOwnPost(userID, postID string) error {
var p model.Post
if err := s.db.Where("id = ?", postID).First(&p).Error; err != nil {
return ErrNotFound
}
if p.UserID != userID {
return ErrForbidden
}
return s.db.Model(&model.Post{}).Where("id = ?", postID).
Update("status", model.PostDeleted).Error
}
// DeleteOwnComment 用户删除自己的评论,并同步帖子评论数
func (s *Service) DeleteOwnComment(userID, commentID string) error {
var c model.Comment
if err := s.db.Where("id = ?", commentID).First(&c).Error; err != nil {
return ErrNotFound
}
if c.UserID != userID {
return ErrForbidden
}
if c.Status == "deleted" {
return nil // 幂等
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Comment{}).Where("id = ?", commentID).
Update("status", "deleted").Error; err != nil {
return err
}
return tx.Model(&model.Post{}).Where("id = ? AND comment_count > 0", c.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count - 1")).Error
})
}
+3
View File
@@ -18,6 +18,9 @@ var ErrNotFound = errors.New("not found")
// 避免「点一下白拿会员」。
var ErrPayNotReady = errors.New("支付功能即将开放,敬请期待")
// ErrForbidden 越权操作(如删除别人的内容)
var ErrForbidden = errors.New("无权操作")
// Service 业务逻辑聚合,方法按领域分散在各文件
type Service struct {
db *gorm.DB
@@ -52,7 +52,8 @@ Component({
properties: {
show: { type: Boolean, value: false },
type: { type: String, value: '' },
postId: { type: Number, value: 0 },
// 雪花 ID 是 18 位字符串,声明成 Number 会超出 JS 安全整数范围而丢精度
postId: { type: String, value: '' },
},
data: {
innerType: '',
@@ -182,13 +183,15 @@ Component({
} else if (type === 'exportData') {
this.buildExport();
} else if (type === 'comments' && this.data.postId) {
api.listComments(this.data.postId).then((page) => this.setData({ comments: page.list || [] })).catch(() => {});
this.loadComments();
} else if (type === 'reminders' && id) {
this.loadReminders();
} else if (type === 'poster' && id) {
api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {});
} else if (type === 'reportDetail' && id) {
api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {});
} else if (type === 'ai') {
this.loadAIHistory();
} else if (type === 'pro') {
api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {});
}
@@ -244,6 +247,28 @@ Component({
onCostNote(e) { this.setData({ costNote: e.detail.value }); },
onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
loadComments() {
if (!this.data.postId) return;
api.listComments(this.data.postId)
.then((page) => this.setData({ comments: page.list || [] }))
.catch(() => {});
},
// 长按删除自己的评论
onDeleteComment(e) {
const c = this.data.comments[e.currentTarget.dataset.index];
if (!c || !c.is_self) return;
wx.showModal({
title: '删除评论',
content: '确定删除这条评论?',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api.deleteComment(c.id)
.then(() => { this.loadComments(); this.triggerEvent('commented'); })
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// ---- 多宠物管理 ----
loadMyPets() {
const cur = store.currentPetId();
@@ -722,6 +747,17 @@ Component({
// AI 聊天
onAiInput(e) { this.setData({ aiInput: e.detail.value }); },
// 拉取历史对话,接在开场白之后;没有历史就保持开场白
loadAIHistory() {
api
.aiMessages('sheet', 30)
.then((list) => {
if (!list || !list.length) return;
const history = list.map((m) => ({ role: m.role === 'user' ? 'user' : 'ai', text: m.text }));
this.setData({ aiMessages: this.data.aiMessages.concat(history) });
})
.catch(() => {});
},
sendAI(e) {
const preset = e.currentTarget.dataset.text;
const text = (preset || this.data.aiInput || '').trim();
@@ -331,8 +331,12 @@ module.exports.sel = function (map, key, index, def) {
<!-- 评论 -->
<block wx:elif="{{innerType === 'comments'}}">
<view class="sheet-h3">评论</view>
<view wx:for="{{comments}}" wx:key="id" class="record-row"><text class="bold">{{item.author_name}}</text><text class="muted">{{item.content}}</text></view>
<view wx:for="{{comments}}" wx:key="id" class="record-row" bindlongpress="onDeleteComment" data-index="{{index}}">
<text class="bold">{{item.author_name}}<text wx:if="{{item.is_self}}" class="mine-tag">我</text></text>
<text class="muted">{{item.content}}</text>
</view>
<view wx:if="{{!comments.length}}" class="sheet-p">还没有评论,来抢沙发~</view>
<view wx:if="{{comments.length}}" class="sheet-p" style="font-size:22rpx;color:var(--muted)">长按自己的评论可删除</view>
<view class="ai-input-row" style="margin-top:24rpx">
<input class="input" placeholder="友善交流,分享经验..." placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"/>
<button class="btn btn-primary ai-send" bindtap="onSendComment">发</button>
@@ -61,3 +61,4 @@
border:1rpx solid var(--line);margin-bottom:24rpx;
}
.export-box::-webkit-scrollbar{width:0;height:0;display:none}
.mine-tag{margin-left:10rpx;font-size:20rpx;font-weight:700;color:var(--primary-dark);background:#FFF2DC;padding:2rpx 10rpx;border-radius:999rpx}
+19 -3
View File
@@ -32,7 +32,7 @@ Page({
posts: [],
sheetShow: false,
sheetType: '',
sheetPostId: 0,
sheetPostId: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet }));
@@ -77,7 +77,7 @@ Page({
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: 0, sheetShow: true });
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
@@ -93,7 +93,23 @@ Page({
wx.navigateTo({ url: '/pages/learn/learn' });
},
onFab() {
this.setData({ sheetType: 'ai', sheetPostId: 0, sheetShow: true });
this.setData({ sheetType: 'ai', sheetPostId: '', sheetShow: true });
},
// 长按自己的帖子可删除
onLongPressPost(e) {
const p = this.data.posts[e.currentTarget.dataset.index];
if (!p || !p.is_self) return;
wx.showModal({
title: '删除帖子',
content: '确定删除这条帖子?删除后其他人将看不到。',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api.deletePost(p.id)
.then(() => { wx.showToast({ title: '已删除', icon: 'success' }); this.loadPosts(); })
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// 关注 / 取关帖子作者
toggleFollow(e) {
+1 -1
View File
@@ -19,7 +19,7 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
<button class="btn btn-primary btn-block" bindtap="openSheet" data-type="createPost"> 发布图文</button>
</view>
<view wx:for="{{posts}}" wx:key="id" class="post-card">
<view wx:for="{{posts}}" wx:key="id" class="post-card" bindlongpress="onLongPressPost" data-index="{{index}}">
<view class="post-head">
<view class="post-avatar">{{item.author_emoji}}</view>
<view class="post-user"><text class="pu-b">{{item.author_name}}<text wx:if="{{item.is_ai}}" class="ai-tag">AI</text></text><text class="pu-s">宠友</text></view>
+46 -2
View File
@@ -16,6 +16,7 @@ function fmtTime(iso) {
}
function mapRecord(r) {
return {
id: r.id,
icon: r.icon || '✍️',
title: r.title,
desc: fmtTime(r.occurred_at) + (r.description ? '' + r.description : ''),
@@ -23,6 +24,19 @@ function mapRecord(r) {
};
}
// 时间轴类型筛选
const REC_FILTERS = [
{ key: '', label: '全部' },
{ key: 'weight', label: '体重' },
{ key: 'poop', label: '便便' },
{ key: 'food', label: '饮食' },
{ key: 'symptom', label: '异常' },
{ key: 'medicine', label: '用药' },
{ key: 'vaccine', label: '疫苗' },
{ key: 'cost', label: '消费' },
{ key: 'photo', label: '照片' },
];
function fmtDay(iso) {
if (!iso) return '';
const d = new Date(iso);
@@ -63,6 +77,8 @@ Page({
recPage: 1,
recTotal: 0,
recHasMore: false,
recFilters: REC_FILTERS,
recFilterIdx: 0,
trendSvg: '',
trendStats: null,
trendDots: [],
@@ -92,11 +108,39 @@ Page({
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
curFilter() {
return this.data.recFilters[this.data.recFilterIdx].key;
},
onFilterTap(e) {
const i = Number(e.currentTarget.dataset.index);
if (i === this.data.recFilterIdx) return;
this.setData({ recFilterIdx: i }, () => this.loadRecords());
},
// 长按删除一条记录
onDeleteRecord(e) {
const r = this.data.timeline[e.currentTarget.dataset.index];
if (!r || !r.id) return;
wx.showModal({
title: '删除记录',
content: '确定删除「' + r.title + '」?删除后不可恢复。',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api
.deleteRecord(r.id)
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' });
this.loadRecords();
})
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
loadRecords() {
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id, { page: 1, pageSize: REC_PAGE })
.getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const list = (res.list || []).map(mapRecord);
this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) });
@@ -109,7 +153,7 @@ Page({
if (!id) return;
const next = this.data.recPage + 1;
api
.getRecords(id, { page: next, pageSize: REC_PAGE })
.getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const merged = this.data.timeline.concat((res.list || []).map(mapRecord));
this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) });
+11 -3
View File
@@ -50,16 +50,24 @@
<view class="card">
<view class="section-head"><view class="sh-title">健康时间轴</view><view class="link" data-type="vetSummary" bindtap="openSheet">摘要</view></view>
<scroll-view class="rec-filter" scroll-x="true" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{recFilters}}" wx:key="key"
class="rf-chip {{recFilterIdx === index ? 'on' : ''}}"
data-index="{{index}}" bindtap="onFilterTap">{{item.label}}</view>
</scroll-view>
<view class="health-timeline">
<view wx:for="{{timeline}}" wx:key="index" class="health-event">
<view wx:for="{{timeline}}" wx:key="id" class="health-event"
bindlongpress="onDeleteRecord" data-index="{{index}}">
<view class="event-dot">{{item.icon}}</view>
<view class="he-body">
<text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view>
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" bindtap="previewImage" data-src="{{item.image}}"></image>
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" catchtap="previewImage" data-src="{{item.image}}"></image>
</view>
</view>
<view wx:if="{{timeline.length === 0}}" class="tl-empty">还没有记录,用上面的按钮记一条吧</view>
<view wx:if="{{timeline.length === 0}}" class="tl-empty">这个分类下还没有记录</view>
</view>
<view wx:if="{{timeline.length}}" class="tl-tip">长按任意一条可删除</view>
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
</view>
+9
View File
@@ -50,3 +50,12 @@
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
/* 时间轴类型筛选 */
.rec-filter{white-space:nowrap;margin-bottom:20rpx}
.rf-chip{
display:inline-block;padding:10rpx 26rpx;margin-right:14rpx;border-radius:999rpx;
background:#fff;border:1rpx solid var(--line);color:var(--muted);font-size:24rpx;font-weight:700;
}
.rf-chip.on{background:#FFF2DC;color:var(--primary-dark);border-color:#FFD39A}
.tl-tip{margin-top:16rpx;text-align:center;color:var(--muted);font-size:22rpx}
+5
View File
@@ -54,6 +54,7 @@ const api = {
return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` });
},
createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }),
deleteRecord: (recordId) => request({ url: `/api/records/${recordId}`, method: 'DELETE' }),
weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }),
// 任务
@@ -92,6 +93,8 @@ const api = {
likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }),
followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }),
unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }),
deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }),
deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }),
listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
createComment: (id, content) =>
request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content } }),
@@ -106,6 +109,8 @@ const api = {
activatePro: () => request({ url: '/api/pro/activate', method: 'POST' }),
// AI
aiMessages: (session, limit) =>
request({ url: `/api/ai/messages?session=${session || ''}&limit=${limit || 30}` }),
aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }),
assessSymptom: (id, body) =>
request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }),