Files
past-go/past-go/internal/handler/diary.go
T
Blizzard 4c9708b562 feat: add WeChat Pay, Redis logging, storage tiers and personal albums
Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
2026-11-02 23:40:49 +08:00

345 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"fmt"
"regexp"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://we.sundynix.cn/backend/'),MEDIA_BASE 未定义
const mediaBase = "https://we.sundynix.cn/backend/"
var tagRe = regexp.MustCompile(`<[^>]*>`)
// stripTags 近似 PHP strip_tags
func stripTags(s string) string { return tagRe.ReplaceAllString(s, "") }
// mbSubstr 对应 mb_substr($s, 0, n)
func mbSubstr(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
// avatarURL 对应评论里的 avatar_url 拼接
func avatarURL(avatar string) string {
if avatar == "" {
return ""
}
if strings.HasPrefix(avatar, "http") || strings.HasPrefix(avatar, "/api/files/") {
return avatar
}
return mediaBase + avatar
}
// diaryDateOrFallback 对应 PHP !empty($input['diary_date']) 的日期默认逻辑。
// 缺失、null、空字符串和纯空白都不允许进入 diary_date NOT NULL 列。
func diaryDateOrFallback(value any, fallback string) string {
if date := strings.TrimSpace(store.ToStr(value)); date != "" {
return date
}
return fallback
}
func registerDiary(r *httpapi.Router) {
r.Handle("diary_list", diaryList)
r.Handle("diary_add", diaryAdd)
r.Handle("diary_update", diaryUpdate)
r.Handle("diary_delete", diaryDelete)
r.Handle("diary_detail", diaryDetail)
r.Handle("diary_comment_list", diaryCommentList)
r.Handle("diary_comment_add", diaryCommentAdd)
r.Handle("diary_comment_delete", diaryCommentDelete)
}
func diaryList(c *httpapi.Ctx) error {
ids := coupleUserIds(c, "")
if len(ids) == 0 {
demo := demoData("diary")
return c.OK(M{"items": demo, "total": len(demo)})
}
page := c.QInt("page")
if !c.QHas("page") {
page = 1
}
if page < 1 {
page = 1
}
pageSize := int64(10)
if c.QHas("page_size") {
pageSize = c.QInt("page_size")
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > 50 {
pageSize = 50
}
offset := (page - 1) * pageSize
cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diaries WHERE created_by IN (?, ?)", ids[0], ids[1])
if err != nil {
return err
}
uid := c.UID()
items, err := c.DB.Query(`SELECT d.*,
(SELECT COUNT(*) FROM diary_comments WHERE diary_id = d.id) as comment_count,
(SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id) as like_count,
(SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id) as favorite_count,
(SELECT COUNT(*) FROM diary_likes WHERE diary_id = d.id AND user_id = ?) > 0 as is_liked,
(SELECT COUNT(*) FROM diary_favorites WHERE diary_id = d.id AND user_id = ?) > 0 as is_favorited
FROM diaries d WHERE d.created_by IN (?, ?) ORDER BY d.diary_date DESC, d.created_at DESC LIMIT ? OFFSET ?`,
uid, uid, ids[0], ids[1], pageSize, offset)
if err != nil {
return err
}
for _, it := range items {
it["images"] = jsonDecodeList(it["images"])
it["is_liked"] = store.Truthy(it["is_liked"])
it["is_favorited"] = store.Truthy(it["is_favorited"])
}
return c.OK(M{"items": rows(items), "total": cnt["cnt"], "page": page, "page_size": pageSize})
}
func diaryAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
diaryDate := diaryDateOrFallback(c.In("diary_date"), today())
images := inOr(c, "images", []any{})
title := inOr(c, "title", "")
content := inOr(c, "content", "")
titleS, contentS := store.ToStr(title), store.ToStr(content)
region := c.InTrim("region")
lat, lng := c.InFloat("lat"), c.InFloat("lng")
location := c.InTrim("location")
if lat != 0 && lng != 0 && location == "" {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
if err := checkTextOr400(c, strings.TrimSpace(titleS+" "+contentS)); err != nil {
return err
}
id, err := c.DB.Insert("INSERT INTO diaries (couple_id, title, content, mood, weather, diary_date, images, is_anniversary, region, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
cid, title, content, inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, jsonEncode(images), inOr(c, "is_anniversary", 0), region, lat, lng, location, uid)
if err != nil {
return err
}
imgCount := 0
if l, ok := images.([]any); ok {
imgCount = len(l)
}
desc := "发表了日记"
if store.Truthy(titleS) {
desc += "「" + titleS + "」"
}
if imgCount > 0 {
desc += fmt.Sprintf(",上传了%d张图片", imgCount)
}
actID := logActivity(c.DB, cid, uid, "diary_create", desc, "")
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
displayTitle := "一篇新日记"
if store.Truthy(titleS) {
displayTitle = titleS
}
displayTitle = mbTruncate(displayTitle, 20)
displayContent := "TA写了一些心里话"
if store.Truthy(contentS) {
displayContent = mbSubstr(stripTags(contentS), 50)
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": displayTitle, "thing2": displayContent, "date3": today(), "thing4": "快去看看吧 💕",
}, "pages/diary/diary", actID, uid)
}
return c.OK(M{"success": true, "id": fmt.Sprint(id)})
}
func diaryUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
images := inOr(c, "images", []any{})
// 旧版小程序的编辑页不传 diary_date。空值时保留数据库原日期;
// 若历史上已被临时改成可空且存在 NULL,则回填当天,保证不再写入 NULL。
diaryDate := strings.TrimSpace(c.InStr("diary_date"))
dateExpr := "COALESCE(NULLIF(?, ''), NULLIF(diary_date, ''), ?)"
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("content"))); err != nil {
return err
}
if c.InHas("lat") {
_, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ?, lat = ?, lng = ?, location = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.InFloat("lat"), c.InFloat("lng"), c.InTrim("location"), c.In("id"), ids[0], ids[1])
} else {
_, err = c.DB.Exec("UPDATE diaries SET title = ?, content = ?, mood = ?, weather = ?, diary_date = "+dateExpr+", images = ?, is_anniversary = ?, region = ? WHERE id = ? AND created_by IN (?, ?)",
c.In("title"), inOr(c, "content", ""), inOr(c, "mood", "😊"), inOr(c, "weather", "☀️"), diaryDate, today(), jsonEncode(images), inOr(c, "is_anniversary", 0), c.InTrim("region"), c.In("id"), ids[0], ids[1])
}
if err != nil {
return err
}
return c.OK(M{"success": true})
}
func diaryDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
if _, err := c.DB.Exec("DELETE FROM diaries WHERE id = ? AND created_by IN (?, ?)", c.In("id"), ids[0], ids[1]); err != nil {
return err
}
return c.OK(M{"success": true})
}
func countOf(c *httpapi.Ctx, sql string, args ...any) int64 {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return 0
}
return r.Int("cnt")
}
func diaryDetail(c *httpapi.Ctx) error {
id := c.Q("id")
if id == "" {
return c.Err(400, "缺少日记ID")
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
uid := c.UID()
item, err := c.DB.QueryOne("SELECT d.*, u.nickname as creator_name, u.avatar as creator_avatar FROM diaries d LEFT JOIN users u ON d.created_by = u.id WHERE d.id = ? AND d.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if item == nil {
return c.Err(404, "日记不存在")
}
item["images"] = jsonDecodeList(item["images"])
item["comment_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_comments WHERE diary_id = ?", id)
if _, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id); err != nil {
return err
}
item["like_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ?", id)
item["favorite_count"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ?", id)
item["is_liked"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_likes WHERE diary_id = ? AND user_id = ?", id, uid) > 0
item["is_favorited"] = countOf(c, "SELECT COUNT(*) as cnt FROM diary_favorites WHERE diary_id = ? AND user_id = ?", id, uid) > 0
return c.OK(item)
}
func diaryCommentList(c *httpapi.Ctx) error {
diaryID := c.Q("diary_id")
if diaryID == "" {
return c.Err(400, "缺少日记ID")
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
suffix, window := listWindow(c)
comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at DESC, c.id DESC"+suffix, append([]any{diaryID, ids[0], ids[1]}, window...)...)
if err != nil {
return err
}
for _, cm := range comments {
cm["avatar_url"] = avatarURL(cm.Str("avatar"))
}
return listResponse(c, comments, countC(c, "SELECT COUNT(*) as c FROM diary_comments WHERE diary_id=? AND couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?))", diaryID, ids[0], ids[1]), nil)
}
func diaryCommentAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid, err := requireCoupleID(c)
if err != nil {
return err
}
uid := c.UID()
if uid == "" {
return c.Err(401, "请先登录")
}
diaryID := c.InTrim("diary_id")
content := c.InTrim("content")
if diaryID == "" {
return c.Err(400, "缺少日记ID")
}
if !store.Truthy(content) {
return c.Err(400, "请输入评论内容")
}
if err := checkTextOr400(c, content); err != nil {
return err
}
diary, _ := c.DB.QueryOne("SELECT id, created_by FROM diaries WHERE id = ? AND couple_id = ?", diaryID, cid)
if diary == nil {
return c.Err(404, "日记不存在")
}
parentID := c.InTrim("parent_id")
var replyToUserID any
if c.InHas("reply_to_user_id") {
replyToUserID = c.InTrim("reply_to_user_id")
}
commentID, err := c.DB.Insert("INSERT INTO diary_comments (diary_id, couple_id, user_id, content, parent_id, reply_to_user_id, reply_to_username) VALUES (?, ?, ?, ?, ?, ?, ?)",
diaryID, cid, uid, content, parentID, replyToUserID, c.InTrim("reply_to_username"))
if err != nil {
return err
}
comment, _ := c.DB.QueryOne("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.id = ?", commentID)
var out any = false
if comment != nil {
comment["avatar_url"] = avatarURL(comment.Str("avatar"))
out = comment
}
// 通知对象:优先被回复的人,否则日记作者;自己评论自己不通知
var notifyTo string
if replyToUserID != nil {
notifyTo = store.ToStr(replyToUserID)
}
if notifyTo == "" {
notifyTo = diary.Str("created_by")
}
if notifyTo != "" && notifyTo != uid {
actID := logActivity(c.DB, cid, uid, "diary_comment_add", "评论了你的日记", "")
c.App.Wechat.SendSubscribeMessage(notifyTo, "notify", map[string]string{
"thing1": "日记有新评论 💬",
"thing2": mbTruncate("TA评论了你:"+content, 20),
"date3": today(),
"thing4": "点击查看详情",
}, "pages/diary/diary", actID, uid)
}
return c.OK(M{"success": true, "comment": out})
}
func diaryCommentDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
if uid == "" {
return c.Err(401, "请先登录")
}
commentID := c.InTrim("id")
if commentID == "" {
return c.Err(400, "缺少评论ID")
}
if _, err := c.DB.Exec("DELETE FROM diary_comments WHERE id = ? AND user_id = ?", commentID, uid); err != nil {
return err
}
return c.OK(M{"success": true})
}