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.
422 lines
15 KiB
Go
422 lines
15 KiB
Go
// 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
|
||
}
|