// 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 }