Files
past-go/past-go/internal/handler/common.go
T
Blizzard 8be6d9b1ab feat: add account deletion, announcements and subscription renewal
Support personal records and persistent smoking visibility. Add upload quota preflight, file cleanup, privacy entry, image caching and revision-based page refresh across the backend, admin and mini program.
2026-11-03 22:06:47 +08:00

422 lines
15 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 按模块实现全部 action,与 backend/api/index.php 一一对应。
package handler
import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"strings"
"time"
"unicode/utf16"
"unicode/utf8"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
type M = httpapi.M
// deps 注入的依赖(上传器等)
var up *upload.Uploader
// Register 注册全部 action
func Register(r *httpapi.Router, uploader *upload.Uploader) {
up = uploader
r.SetFileStorage(uploader)
registerUser(r)
registerCouple(r)
registerTimeline(r)
registerAlbum(r)
registerDiary(r)
registerSongs(r)
registerCheckin(r)
registerPeriod(r)
registerBg(r)
registerSite(r)
registerActivity(r)
registerSubscribe(r)
registerPet(r)
registerAdmin(r)
registerStorage(r)
r.Handle("announcement", announcement)
r.Handle("admin_announcement", adminAnnouncement)
r.Handle("account_deletion_preview", accountDeletionPreview)
r.Handle("account_delete", accountDelete)
}
func now() string { return time.Now().Format("2006-01-02 15:04:05") }
// needLoginRecord 对应“从未登录过,或距上次登录超过 5 分钟”
func needLoginRecord(lastLoginAt string) bool {
if lastLoginAt == "" {
return true
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", lastLoginAt, time.Local)
return err != nil || time.Since(t) > 300*time.Second
}
func today() string { return time.Now().Format("2006-01-02") }
func daysAgo(n int) string { return time.Now().AddDate(0, 0, -n).Format("2006-01-02") }
// startOfToday 返回今天 00:00(本地时区)。姨姨每日标记只写「已确认」的日期,
// 未来日期尚未确认经期量,不预先标记为有经期,避免把预估结束日误判为实际经期。
func startOfToday() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
}
// rows 保证列表非 nil(JSON 输出 [] 而非 null)
func rows(r []store.Row) []store.Row {
if r == nil {
return []store.Row{}
}
return r
}
// generateUserId 对应 generateUserId($db):520 开头顺序递增,记录在 settings.last_user_id
// generateUserId uses a PostgreSQL sequence; simultaneous registrations cannot collide.
func generateUserId(db store.Querier) (string, error) {
row, err := db.QueryOne("SELECT nextval('past_user_display_id') AS display_id")
if err != nil {
return "", err
}
if row == nil {
return "", fmt.Errorf("用户编号生成失败")
}
return row.Str("display_id"), nil
}
// generateCoupleSpaceId 对应 'CP' . str_pad($coupleId, 6, '0', STR_PAD_LEFT)
func generateCoupleSpaceId(coupleID string) string {
return "CP" + strings.ReplaceAll(coupleID, "-", "")
}
// logActivity 对应 logActivity(),返回 activity id
func logActivity(db store.Querier, coupleID, userID string, actionType, description, detail string) string {
if coupleID == "" {
return ""
}
id, _ := db.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, detail)
return id
}
// coupleUserIds 对应 getCoupleUserIds($coupleId):返回 [user1_id, user2_id],无则空
func coupleUserIds(c *httpapi.Ctx, coupleID string) []string {
if coupleID == "" {
coupleID = c.CoupleID()
if coupleID == "" {
return nil
}
}
row, _ := c.DB.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", coupleID)
if row == nil {
return nil
}
return []string{row.Str("user1_id"), row.Str("user2_id")}
}
// partnerUserId 对应 getPartnerUserId($userId);无则 0
func partnerUserId(db store.Querier, userID string) string {
u, _ := db.QueryOne("SELECT couple_id FROM users WHERE id = ?", userID)
if u == nil || u.Str("couple_id") == "" {
return ""
}
cp, _ := db.QueryOne("SELECT user1_id, user2_id FROM couples WHERE id = ?", u.Str("couple_id"))
if cp == nil {
return ""
}
if cp.Str("user1_id") == userID {
return cp.Str("user2_id")
}
return cp.Str("user1_id")
}
// userOpenId 对应 getUserOpenId($uid)
func userOpenId(db store.Querier, uid string) string {
if uid == "" {
return ""
}
row, _ := db.QueryOne("SELECT openid FROM users WHERE id = ?", uid)
if row == nil {
return ""
}
return row.Str("openid")
}
// executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料
func executeUnbind(db store.Querier, cid string) {
db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid)
db.Exec("DELETE FROM diaries WHERE couple_id = ?", cid)
db.Exec("DELETE FROM songs WHERE couple_id = ?", cid)
db.Exec("DELETE FROM checkin_logs WHERE couple_id = ?", cid)
db.Exec("DELETE FROM checkins WHERE couple_id = ?", cid)
albums, _ := db.Query("SELECT id FROM albums WHERE couple_id = ?", cid)
for _, a := range albums {
db.Exec("DELETE FROM photos WHERE album_id = ?", a.Str("id"))
}
db.Exec("DELETE FROM albums WHERE couple_id = ?", cid)
db.Exec("DELETE FROM activity_log WHERE couple_id = ?", cid)
users, _ := db.Query("SELECT id FROM users WHERE couple_id = ?", cid)
if len(users) > 0 {
ids := make([]any, 0, len(users))
for _, u := range users {
ids = append(ids, u.Str("id"))
}
ph := placeholders(len(ids))
args := append(append([]any{}, ids...), ids...)
db.Exec("DELETE FROM invitations WHERE from_user_id IN ("+ph+") OR to_user_id IN ("+ph+")", args...)
db.Exec("DELETE FROM invite_links WHERE user_id IN ("+ph+")", ids...)
}
db.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid)
db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid)
db.Exec("DELETE FROM couples WHERE id = ?", cid)
}
// createDefaultAlbums 对应 createDefaultAlbums()
func createDefaultAlbums(db store.Querier, coupleID, userID string) {
defaults := [][2]string{
{"日常甜蜜", "我们的小日常"},
{"旅行记录", "一起走过的风景"},
{"美食打卡", "一起吃的每一顿饭"},
{"生活碎片", "生活中的点点滴滴"},
{"节日纪念", "特别的节日和纪念日"},
{"自拍合照", "我们的精彩瞬间"},
}
for i, d := range defaults {
db.Exec("INSERT INTO albums (couple_id, title, description, sort_order, created_by) VALUES (?, ?, ?, ?, ?)", coupleID, d[0], d[1], i, userID)
}
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// resolveLoginLocation 对应 resolveLoginLocation($input, $fallback)
func resolveLoginLocation(c *httpapi.Ctx, fallback string) string {
raw := strings.TrimSpace(c.InStr("location"))
if raw == "" {
raw = strings.TrimSpace(fallback)
}
if raw == "" {
return ""
}
if strings.Contains(raw, ",") {
parts := strings.Split(raw, ",")
if len(parts) == 2 && isNumeric(strings.TrimSpace(parts[0])) && isNumeric(strings.TrimSpace(parts[1])) {
lat := store.ToFloat(strings.TrimSpace(parts[0]))
lng := store.ToFloat(strings.TrimSpace(parts[1]))
if addr := c.App.Geo.ReverseGeocode(lat, lng); addr != "" {
return addr
}
}
}
return raw
}
// isNumeric 对应 PHP is_numeric
func isNumeric(s string) bool {
if s == "" {
return false
}
var f float64
_, err := fmt.Sscanf(s, "%g", &f)
if err != nil {
return false
}
// Sscanf 允许前缀匹配,再严格检查字符集
for _, ch := range s {
if !(ch >= '0' && ch <= '9') && ch != '.' && ch != '-' && ch != '+' && ch != 'e' && ch != 'E' {
return false
}
}
return true
}
// demoData 对应 getDemoData($module)
func demoData(module string) []M {
ts := now()
switch module {
case "timeline":
return []M{
{"id": -1, "title": "第一次相遇", "content": "还记得那天阳光正好,你穿着白色的衬衫,在图书馆的角落里安静地看书。", "event_date": daysAgo(30), "location": "图书馆", "images": []any{}, "icon": "❤️", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "第一次约会", "content": "一起看了场电影,吃了顿美味的晚餐,聊了很多很多。", "event_date": daysAgo(25), "location": "电影院", "images": []any{}, "icon": "🎬", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "一起做饭", "content": "第一次一起下厨,虽然把厨房弄得一团糟,但很开心。", "event_date": daysAgo(20), "location": "家", "images": []any{}, "icon": "🍳", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "公园散步", "content": "傍晚在公园散步,夕阳很美,你更美。", "event_date": daysAgo(15), "location": "公园", "images": []any{}, "icon": "🌅", "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "纪念日", "content": "属于我们的特别日子,值得永远铭记。", "event_date": daysAgo(10), "location": "", "images": []any{}, "icon": "🎉", "sort_order": 0, "created_by": 0, "created_at": ts},
}
case "album":
return []M{
{"id": -1, "title": "甜蜜日常", "description": "记录我们的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "旅行记忆", "description": "一起走过的风景", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "美食打卡", "description": "一起吃的每一顿饭", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "生活碎片", "description": "生活中的点点滴滴", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "我们的故事", "description": "从开始到现在", "cover": "", "photo_count": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
}
case "diary":
return []M{
{"id": -1, "title": "初识", "content": "今天在图书馆遇见了你,心跳加速的感觉真好。", "mood": "😊", "weather": "☀️", "diary_date": daysAgo(5), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -2, "title": "美好的一天", "content": "和你在一起的时间总是过得太快,期待下次见面。", "mood": "🥰", "weather": "☀️", "diary_date": daysAgo(4), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -3, "title": "小确幸", "content": "收到你的消息,一整天的心情都变好了。", "mood": "💕", "weather": "⛅", "diary_date": daysAgo(3), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -4, "title": "想念", "content": "今天没有见到你,好想你呀。", "mood": "😔", "weather": "🌧️", "diary_date": daysAgo(2), "images": []any{}, "created_by": 0, "created_at": ts},
{"id": -5, "title": "期待未来", "content": "想象和你一起的每一个明天,都是那么美好。", "mood": "💖", "weather": "☀️", "diary_date": daysAgo(1), "images": []any{}, "created_by": 0, "created_at": ts},
}
case "songs":
return []M{
{"id": -1, "song_name": "告白气球", "artist": "周杰伦", "note": "第一次约会时听的歌", "created_by": 0, "created_at": ts},
{"id": -2, "song_name": "小情歌", "artist": "苏打绿", "note": "你最喜欢的一首歌", "created_by": 0, "created_at": ts},
{"id": -3, "song_name": "想你的夜", "artist": "关喆", "note": "每次想你的时候都会听", "created_by": 0, "created_at": ts},
{"id": -4, "song_name": "有点甜", "artist": "汪苏泷", "note": "我们的主题曲", "created_by": 0, "created_at": ts},
{"id": -5, "song_name": "最美的期待", "artist": "周笔畅", "note": "对未来充满期待", "created_by": 0, "created_at": ts},
}
case "checkin":
return []M{
{"id": -1, "title": "每天说爱你", "icon": "❤️", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -2, "title": "一起运动", "icon": "🏃", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -3, "title": "早睡打卡", "icon": "🌙", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -4, "title": "喝水提醒", "icon": "💧", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
{"id": -5, "title": "看书学习", "icon": "📚", "type": "both", "frequency": "daily", "is_completed": 0, "sort_order": 0, "created_by": 0, "created_at": ts},
}
}
return []M{}
}
// inOr 对应 $input['key'] ?? $def(null 也视为未设置)
func inOr(c *httpapi.Ctx, key string, def any) any {
if v := c.In(key); v != nil {
return v
}
return def
}
// mbTruncate 对应 mb_strlen($s) > n ? mb_substr($s, 0, n) . '...' : $s
func mbTruncate(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n]) + "..."
}
return s
}
// jsonEncode 对应 PHP json_encode($v) 默认标志(存库用):转义 "/" 为 "\/",非 ASCII 转 \uXXXX(含代理对),
// 使 Go 写入的 JSON 字段与 PHP 历史数据字节一致。
func jsonEncode(v any) string {
if v == nil {
return "null"
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "null"
}
return phpEscapeJSON(strings.TrimRight(buf.String(), "\n"))
}
// phpEscapeJSON 把 Go 的 JSON 文本转换成 PHP json_encode 默认输出形式
func phpEscapeJSON(s string) string {
var sb strings.Builder
inStr := false
for i := 0; i < len(s); {
ch := s[i]
if !inStr {
if ch == '"' {
inStr = true
}
sb.WriteByte(ch)
i++
continue
}
switch {
case ch == '\\':
// 已转义序列(Go 产生的 \uXXXX / \" / \\ / \n 等)原样保留
if i+1 < len(s) {
sb.WriteByte(ch)
sb.WriteByte(s[i+1])
i += 2
} else {
sb.WriteByte(ch)
i++
}
case ch == '"':
inStr = false
sb.WriteByte(ch)
i++
case ch == '/':
sb.WriteString("\\/")
i++
case ch >= 0x80:
r, size := utf8.DecodeRuneInString(s[i:])
if r >= 0x10000 {
r1, r2 := utf16.EncodeRune(r)
fmt.Fprintf(&sb, "\\u%04x\\u%04x", r1, r2)
} else {
fmt.Fprintf(&sb, "\\u%04x", r)
}
i += size
default:
sb.WriteByte(ch)
i++
}
}
return sb.String()
}
// jsonDecodeList 对应 json_decode($s, true) ?: []:解析失败或为空返回空数组
func jsonDecodeList(v any) any {
s := store.ToStr(v)
if s == "" {
return []any{}
}
var out any
if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
return []any{}
}
switch x := out.(type) {
case []any:
if len(x) == 0 {
return []any{}
}
case map[string]any:
if len(x) == 0 {
return []any{}
}
case bool:
if !x {
return []any{}
}
case float64:
if x == 0 {
return []any{}
}
case string:
if x == "" || x == "0" {
return []any{}
}
}
return out
}
// countRaw 对应 $stmt->fetch()['cnt'] 原样输出(未经 intval,PHP 8.0 下为字符串)
func countRaw(c *httpapi.Ctx, col, sql string, args ...any) any {
r, _ := c.DB.QueryOne(sql, args...)
if r == nil {
return nil
}
return r[col]
}
// randInt 对应 random_int(min, max)
func randInt(min, max int) int { return min + rand.Intn(max-min+1) }
func nullableCoupleID(id string) any {
if id == "" {
return nil
}
return id
}