Files
past-go/past-go/internal/handler/user.go
T
CodeBuddy bf99e52937 feat: 存储空间管理、虚拟支付接入与容量守卫
- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询
- 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order)
- 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数
- 前端上传前容量预检,超容引导升级无限空间
- 我的页会员卡片展示「无限空间」与已用容量/到期时间
- 后台「存储空间管理」新增支付记录标签页
2026-10-30 23:45:39 +08:00

648 lines
21 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 (
"crypto/rand"
"encoding/hex"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strings"
"golang.org/x/crypto/bcrypt"
"past/internal/geo"
"past/internal/httpapi"
"past/internal/store"
"past/internal/upload"
)
var phoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`)
// hashPassword 对应 password_hash($pw, PASSWORD_DEFAULT),输出 $2y$ 前缀与 PHP 一致
func hashPassword(pw string) string {
h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return "$2y$" + strings.TrimPrefix(string(h), "$2a$")
}
// verifyPassword 对应 password_verify()
func verifyPassword(pw, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
// last4 对应 substr($s, -4)
func last4(s string) string {
if len(s) <= 4 {
return s
}
return s[len(s)-4:]
}
// ensureUserDirs 创建用户独立文件夹 users/{uid}/{photos,diaries,covers,timeline}
func ensureUserDirs(uid int64) {
base := filepath.Join(up.Dir, "users", fmt.Sprint(uid))
for _, t := range []string{"photos", "diaries", "covers", "timeline"} {
os.MkdirAll(filepath.Join(base, t), 0755)
}
}
func registerUser(r *httpapi.Router) {
r.Handle("register", register)
r.Handle("register_by_invite", registerByInvite)
r.Handle("login", login)
r.Handle("wechat_login", wechatLogin)
r.Handle("check_auth", checkAuth)
r.Handle("logout", logout)
r.Handle("my_info", myInfo)
r.Handle("update_profile", updateProfile)
r.Handle("update_location", updateLocation)
r.Handle("update_ip_location", updateIPLocation)
r.Handle("update_partner_profile", updatePartnerProfile)
r.Handle("upload_avatar", uploadAvatar)
r.Handle("upload_partner_avatar", uploadPartnerAvatar)
}
func register(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
phone := c.InTrim("phone")
password := c.InStr("password")
nickname := c.InTrim("nickname")
if phone == "" {
return c.Err(400, "请输入手机号")
}
if !phoneRe.MatchString(phone) {
return c.Err(400, "请输入正确的手机号")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil {
return c.Err(400, "该手机号已注册")
}
if len(password) < 6 {
return c.Err(400, "密码至少6位")
}
if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass {
return c.Err(400, msg)
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
displayName := nickname
if displayName == "" {
displayName = "用户" + last4(phone)
}
gender := c.InStr("gender")
if gender != "male" && gender != "female" {
gender = ""
}
region := c.InTrim("region")
userID, err := c.DB.Insert("INSERT INTO users (user_id, phone, username, password, nickname, gender, region) VALUES (?, ?, ?, ?, ?, ?, ?)",
displayID, phone, phone, hashPassword(password), displayName, gender, region)
if err != nil {
return err
}
// PHP 每次请求都会执行 install.php 的回填 UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL,
// 效果等价于注册后下一请求即回填;Go 只在启动时执行迁移,因此在此处直接回填
c.DB.Exec("UPDATE users SET last_login_at = created_at WHERE id = ? AND last_login_at IS NULL", userID)
token := httpapi.NewToken()
c.SaveUserToken(userID, token)
// PHP: user_id 来自 lastInsertId(),为字符串
return c.OK(M{"success": true, "user_id": fmt.Sprint(userID), "display_id": displayID, "username": phone, "nickname": displayName, "couple_id": nil, "region": region, "token": token})
}
func registerByInvite(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
phone := c.InTrim("phone")
password := c.InStr("password")
nickname := c.InTrim("nickname")
inviteCode := c.InTrim("invite_code")
if phone == "" {
return c.Err(400, "请输入手机号")
}
if !phoneRe.MatchString(phone) {
return c.Err(400, "请输入正确的手机号")
}
if ex, _ := c.DB.QueryOne("SELECT id FROM users WHERE phone = ? AND phone != ''", phone); ex != nil {
return c.Err(400, "该手机号已注册")
}
if len(password) < 6 {
return c.Err(400, "密码至少6位")
}
if inviteCode == "" {
return c.Err(400, "缺少邀请码")
}
if pass, msg := c.App.Wechat.CheckText(nickname, ""); !pass {
return c.Err(400, msg)
}
link, err := c.DB.QueryOne("SELECT il.*, u.couple_id as inviter_couple_id FROM invite_links il JOIN users u ON il.user_id = u.id WHERE il.code = ? AND il.used = 0", inviteCode)
if err != nil {
return err
}
if link == nil {
return c.Err(400, "邀请链接无效或已使用")
}
if link.Int("inviter_couple_id") != 0 {
return c.Err(400, "邀请人已绑定情侣")
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
displayName := nickname
if displayName == "" {
displayName = "用户" + last4(phone)
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
newUserID, err := tx.Insert("INSERT INTO users (user_id, phone, username, password, nickname) VALUES (?, ?, ?, ?, ?)",
displayID, phone, phone, hashPassword(password), displayName)
if err != nil {
return err
}
inviterID := link.Int("user_id")
coupleID, err := tx.Insert("INSERT INTO couples (user1_id, user2_id) VALUES (?, ?)", inviterID, newUserID)
if err != nil {
return err
}
spaceID := generateCoupleSpaceId(coupleID)
if _, err := tx.Exec("UPDATE couples SET couple_space_id = ? WHERE id = ?", spaceID, coupleID); err != nil {
return err
}
ensureUserDirs(inviterID)
ensureUserDirs(newUserID)
if _, err := tx.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", coupleID, inviterID, newUserID); err != nil {
return err
}
if _, err := tx.Exec("UPDATE invite_links SET used = 1 WHERE id = ?", link.Int("id")); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
createDefaultAlbums(c.DB, coupleID, newUserID)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newUserID)
token := httpapi.NewToken()
c.SaveUserToken(newUserID, token)
return c.OK(M{"success": true, "user_id": fmt.Sprint(newUserID), "display_id": displayID, "username": phone, "couple_id": fmt.Sprint(coupleID), "nickname": displayName, "token": token})
}
func login(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
loginField := c.InTrim("phone")
if loginField == "" {
loginField = c.InTrim("username")
}
password := c.InStr("password")
if loginField == "" {
return c.Err(400, "请输入手机号")
}
user, err := c.DB.QueryOne("SELECT * FROM users WHERE username = ? OR phone = ?", loginField, loginField)
if err != nil {
return err
}
if user == nil {
return c.Err(400, "用户不存在,请先注册")
}
if !verifyPassword(password, user.Str("password")) {
return c.Err(401, "用户名或密码错误")
}
token := httpapi.NewToken()
uid := user.Int("id")
c.SaveUserToken(uid, token)
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if loc := resolveLoginLocation(c, ""); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
}
if user.Int("couple_id") != 0 {
logActivity(c.DB, user.Int("couple_id"), uid, "login", "登录了小程序", "")
}
return c.OK(M{"success": true, "user_id": user["id"], "display_id": user["user_id"], "username": user["username"], "nickname": user["nickname"],
"couple_id": user["couple_id"], "gender": user.Str("gender"), "avatar": user.Str("avatar"), "token": token})
}
func wechatLogin(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
code := c.InTrim("code")
if code == "" {
return c.Err(400, "缺少登录凭证")
}
openid, sessionKey, wxErr, available := c.App.Wechat.Jscode2Session(code)
if !available {
return c.Err(500, "微信登录服务暂不可用")
}
if wxErr != "" {
return c.Err(400, "微信登录失败: "+wxErr)
}
if openid == "" {
return c.Err(400, "获取用户标识失败")
}
user, err := c.DB.QueryOne("SELECT id, user_id, nickname, avatar, gender, birthday, couple_id, region FROM users WHERE openid = ?", openid)
if err != nil {
return err
}
if user != nil {
uid := user.Int("id")
token := httpapi.NewToken()
c.SaveUserToken(uid, token)
if sessionKey != "" {
c.DB.Exec("UPDATE users SET last_login_at = NOW(), session_key = ? WHERE id = ?", sessionKey, uid)
} else {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
}
region := c.InTrim("region")
if region != "" && user.Str("region") == "" {
c.DB.Exec("UPDATE users SET region = ? WHERE id = ?", region, uid)
user["region"] = region
}
if loc := resolveLoginLocation(c, region); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, uid)
}
if cid := user.Int("couple_id"); cid != 0 {
actID := logActivity(c.DB, cid, uid, "login", "登录了小程序", "")
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
nick := user.Str("nickname")
if nick == "" {
nick = "你的TA"
}
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": nick + " 登录了小程序",
"thing2": "TA已上线,快来互动吧 💕",
"date3": today(),
"thing4": "点击查看详情",
}, "pages/index/index", actID, uid)
}
}
return c.OK(M{
"success": true, "is_new": false,
"user_id": user["id"], "display_id": user["user_id"],
"couple_id": user["couple_id"], "nickname": user["nickname"],
"avatar": user.Str("avatar"), "gender": user.Str("gender"),
"birthday": user.Str("birthday"), "region": user.Str("region"),
"token": token,
})
}
displayID, err := generateUserId(c.DB)
if err != nil {
return err
}
region := c.InTrim("region")
defaultNick := "用户" + last4(displayID)
newID, err := c.DB.Insert("INSERT INTO users (user_id, openid, username, password, nickname, region, session_key) VALUES (?, ?, ?, ?, ?, ?, ?)",
displayID, openid, "wx_"+openid, hashPassword(randomHex(8)), defaultNick, region, sessionKey)
if err != nil {
return err
}
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", newID)
if loc := resolveLoginLocation(c, region); loc != "" {
c.DB.Exec("UPDATE users SET last_login_location = ? WHERE id = ?", loc, newID)
}
token := httpapi.NewToken()
c.SaveUserToken(newID, token)
return c.OK(M{
"success": true, "is_new": true,
"user_id": fmt.Sprint(newID), "display_id": displayID,
"couple_id": nil, "nickname": defaultNick,
"avatar": "", "gender": "", "birthday": "", "region": region,
"token": token,
})
}
func checkAuth(c *httpapi.Ctx) error {
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
c.CoupleID()
uid := c.UID()
if uid == 0 {
return c.OK(M{"logged_in": false})
}
// 触发登录记录(5 分钟去重)——逻辑与 requireUserLogin 相同
if user, _ := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid); user != nil {
if needLoginRecord(user.Str("last_login_at")) {
c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid)
if user.Int("couple_id") != 0 {
c.DB.Exec("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, 'login', '登录了小程序', '', NOW())", user.Int("couple_id"), uid)
}
}
}
user, err := c.DB.QueryOne("SELECT id as user_id, user_id as display_id, phone, username, nickname, avatar, gender, couple_id FROM users WHERE id = ?", uid)
if err != nil {
return err
}
return c.OK(M{"logged_in": true, "user": user})
}
func logout(c *httpapi.Ctx) error {
if uid := c.UID(); uid != 0 {
// PHP 只销毁 session(token 仍可通过 user_tokens 兜底);Go 版本同时失效 token,语义更合理
c.DB.Exec("DELETE FROM user_tokens WHERE token = ?", c.Token())
}
return c.OK(M{"success": true})
}
func myInfo(c *httpapi.Ctx) error {
uid := c.UID()
user, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.couple_id, u.created_at FROM users u WHERE u.id = ?", uid)
if err != nil {
return err
}
if user == nil {
return c.OK(false)
}
if user.Int("couple_id") != 0 {
partner, err := c.DB.QueryOne("SELECT u.id, u.username, u.nickname, u.avatar, u.gender, u.age, u.birthday, u.region, u.location, u.lat, u.lng FROM users u INNER JOIN couples c ON (u.id = c.user1_id OR u.id = c.user2_id) WHERE c.id = ? AND u.id != ?", user.Int("couple_id"), uid)
if err != nil {
return err
}
if partner == nil {
user["partner"] = false
} else {
user["partner"] = partner
}
}
return c.OK(user)
}
func updateProfile(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
nickname := c.InTrim("nickname")
avatar := c.InStr("avatar")
gender := c.InStr("gender")
if nickname != "" {
if pass, msg := c.App.Wechat.CheckText(nickname, userOpenId(c.DB, uid)); !pass {
return c.Err(400, msg)
}
}
hasAge := c.InHas("age")
age := c.InInt("age")
birthday := c.InTrim("birthday")
region := c.InTrim("region")
var fields []string
var params []any
var changed []string
if store.Truthy(nickname) {
fields = append(fields, "nickname = ?")
params = append(params, nickname)
changed = append(changed, "昵称")
}
if store.Truthy(avatar) {
fields = append(fields, "avatar = ?")
params = append(params, avatar)
changed = append(changed, "头像")
}
if store.Truthy(gender) {
fields = append(fields, "gender = ?")
params = append(params, gender)
changed = append(changed, "性别")
}
if hasAge {
fields = append(fields, "age = ?")
params = append(params, age)
changed = append(changed, "年龄")
}
if birthday != "" {
fields = append(fields, "birthday = ?")
params = append(params, birthday)
changed = append(changed, "生日")
}
if region != "" {
fields = append(fields, "region = ?")
params = append(params, region)
changed = append(changed, "地区")
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, uid)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 {
logActivity(c.DB, cid, uid, "profile_edit", "修改了个人资料:"+strings.Join(changed, "、"), "")
}
}
return c.OK(M{"success": true})
}
func updateLocation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
location := c.InTrim("location")
if location == "" {
return c.Err(400, "位置不能为空")
}
saved := location
var lat, lng float64
if strings.Contains(location, ",") {
parts := strings.Split(location, ",")
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 != "" {
saved = addr
} else {
old, _ := c.DB.QueryOne("SELECT location FROM users WHERE id = ?", uid)
if old != nil && old.Str("location") != "" && !strings.Contains(old.Str("location"), ",") {
saved = old.Str("location")
} else {
saved = ""
}
}
}
}
if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", saved, lat, lng, uid); err != nil {
return err
}
var distance any
if cid := c.CoupleID(); cid != 0 {
partner, _ := c.DB.QueryOne("SELECT lat, lng FROM users WHERE couple_id = ? AND id != ? AND lat > 0 AND lng > 0", cid, uid)
if partner != nil && lat != 0 && lng != 0 {
distance = geo.Haversine(lat, lng, store.ToFloat(partner["lat"]), store.ToFloat(partner["lng"]))
}
}
return c.OK(M{"success": true, "location": saved, "lat": lat, "lng": lng, "distance": distance})
}
func updateIPLocation(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
var newLat, newLng float64
if c.InHas("lat") {
newLat = c.InFloat("lat")
}
if c.InHas("lng") {
newLng = c.InFloat("lng")
}
u, _ := c.DB.QueryOne("SELECT location, lat, lng, last_ip FROM users WHERE id = ?", uid)
current := ""
var oldLat, oldLng float64
if u != nil {
current = u.Str("location")
oldLat = store.ToFloat(u["lat"])
oldLng = store.ToFloat(u["lng"])
}
if newLat != 0 && newLng != 0 && oldLat != 0 && oldLng != 0 {
dist := geo.Haversine(oldLat, oldLng, newLat, newLng)
if dist <= 5.0 {
return c.OK(M{"success": true, "location": current, "cached": true, "distance": math.Round(dist*100) / 100})
}
}
clientIP := c.ClientIP()
address := ""
if newLat != 0 && newLng != 0 {
address = c.App.Geo.ReverseGeocode(newLat, newLng)
}
if address == "" {
address = c.App.Geo.IPGeolocate(clientIP)
}
if address != "" {
if _, err := c.DB.Exec("UPDATE users SET location = ?, lat = ?, lng = ?, last_ip = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", address, newLat, newLng, clientIP, uid); err != nil {
return err
}
return c.OK(M{"success": true, "location": address, "cached": false})
}
return c.OK(M{"success": true, "location": current, "cached": true})
}
// verifyPartner 校验 partner_id 是当前用户的伴侣;失败时已写出响应
func verifyPartner(c *httpapi.Ctx, uid, partnerID int64) error {
user, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
if user == nil || user.Int("couple_id") == 0 {
return c.Err(400, "你还没有绑定情侣")
}
partner, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ? AND couple_id = ?", partnerID, user.Int("couple_id"))
if partner == nil {
return c.Err(400, "无法修改此用户信息")
}
return nil
}
func updatePartnerProfile(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
uid := c.UID()
partnerID := c.InInt("partner_id")
nickname := c.InTrim("nickname")
avatar := c.InStr("avatar")
gender := c.InTrim("gender")
hasAge := c.InHas("age")
age := c.InInt("age")
birthday := c.InTrim("birthday")
if err := verifyPartner(c, uid, partnerID); err != nil {
return err
}
var fields []string
var params []any
var changed []string
if store.Truthy(nickname) {
fields = append(fields, "nickname = ?")
params = append(params, nickname)
changed = append(changed, "昵称")
}
if store.Truthy(avatar) {
fields = append(fields, "avatar = ?")
params = append(params, avatar)
changed = append(changed, "头像")
}
if store.Truthy(gender) {
fields = append(fields, "gender = ?")
params = append(params, gender)
changed = append(changed, "性别")
}
if hasAge {
fields = append(fields, "age = ?")
params = append(params, age)
changed = append(changed, "年龄")
}
if birthday != "" {
fields = append(fields, "birthday = ?")
params = append(params, birthday)
changed = append(changed, "生日")
}
if len(fields) > 0 {
fields = append(fields, "updated_at = CURRENT_TIMESTAMP")
params = append(params, partnerID)
if _, err := c.DB.Exec("UPDATE users SET "+strings.Join(fields, ", ")+" WHERE id = ?", params...); err != nil {
return err
}
if cid := c.CoupleID(); cid != 0 && len(changed) > 0 {
logActivity(c.DB, cid, uid, "partner_edit", "修改了TA的资料:"+strings.Join(changed, "、"), "")
}
}
return c.OK(M{"success": true})
}
// doAvatarUpload 头像上传公共逻辑;文件字段兼容 avatar / file
func doAvatarUpload(c *httpapi.Ctx, targetUID int64) error {
u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", targetUID)
uidPath := fmt.Sprintf("uid%d", targetUID)
if u != nil {
uidPath = u.Str("user_id")
}
fh := c.File("avatar")
if fh == nil {
fh = c.File("file")
}
if fh == nil {
return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived)
}
res := up.Handle(fh, "avatars", upload.AvatarPath(uidPath))
if res.Path != "" {
if _, err := c.DB.Exec("UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", res.Path, targetUID); err != nil {
return err
}
return c.OK(M{"success": true, "path": res.Path})
}
if strings.Contains(res.Error, "违规") {
return c.ErrCode(400, res.Error, upload.ErrHandleFailed)
}
msg := res.Error
if msg == "" {
msg = "未知错误"
}
return c.ErrCode(400, "上传失败: "+msg, upload.ErrHandleFailed)
}
func uploadAvatar(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
return doAvatarUpload(c, c.UID())
}
func uploadPartnerAvatar(c *httpapi.Ctx) error {
if c.Method != "POST" {
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
}
uid := c.UID()
partnerID := store.ToInt(c.Form("partner_id"))
if err := verifyPartner(c, uid, partnerID); err != nil {
return err
}
return doAvatarUpload(c, partnerID)
}