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

583 lines
20 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"
"strings"
"past/internal/httpapi"
"past/internal/store"
)
func registerPeriod(r *httpapi.Router) {
r.Handle("period_list", periodList)
r.Handle("period_add", periodProtected(periodAdd))
r.Handle("period_update", periodProtected(periodUpdate))
r.Handle("period_delete", periodProtected(periodDelete))
r.Handle("period_visible_get", periodVisibleGet)
r.Handle("period_visible_set", periodVisibleSet)
r.Handle("period_source_select", periodSourceSelect)
r.Handle("period_daily_save", periodProtected(periodDailySave))
r.Handle("period_daily_get", periodProtected(periodDailyGet))
r.Handle("period_sex_add", periodProtected(periodSexAdd))
r.Handle("period_sex_delete", periodProtected(periodSexDelete))
r.Handle("period_sex_list", periodProtected(periodSexList))
}
func periodProtected(next httpapi.HandlerFunc) httpapi.HandlerFunc {
return func(c *httpapi.Ctx) error {
if err := requirePeriodAccess(c); err != nil {
return err
}
if required, _, _, err := periodSourceState(c); err != nil {
return err
} else if required {
return c.Err(409, "请先选择姨姨记录来源")
}
return next(c)
}
}
// cidOrNil 对应 PHP 中直接把 getCoupleId()(可能为 null)绑定进 SQL 的场景:未绑定时传 NULL 而非 0
func cidOrNil(c *httpapi.Ctx) any {
if cid := c.CoupleID(); cid != 0 {
return cid
}
return nil
}
// coupleCond 根据 cid(可能为 nil)构造 couple_id 过滤条件与参数
func coupleCond(cid any) (string, []any) {
if cid == nil {
return "couple_id IS NULL", nil
}
return "couple_id = ?", []any{cid}
}
// 每人独立控制自己是否向伴侣开放同一份姨姨数据。
func periodVisible(c *httpapi.Ctx, uid int64) int64 {
row, _ := c.DB.QueryOne("SELECT period_visible FROM users WHERE id = ?", uid)
if row == nil {
return 0
}
return row.Int("period_visible")
}
func emptyPeriod(visible int64) M {
return M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{}, "period_visible": visible, "can_access": false}
}
type periodAccess struct {
uid, cid, partnerID int64
visible, partnerVisible int64
}
func currentPeriodAccess(c *httpapi.Ctx) periodAccess {
uid := c.UID()
cid := c.CoupleID()
access := periodAccess{uid: uid, cid: cid, visible: periodVisible(c, uid), partnerVisible: 1}
if cid != 0 {
access.partnerVisible = 0
partner, _ := c.DB.QueryOne("SELECT id, period_visible FROM users WHERE couple_id = ? AND id != ? LIMIT 1", cid, uid)
if partner != nil {
access.partnerID = partner.Int("id")
access.partnerVisible = partner.Int("period_visible")
}
}
return access
}
func (a periodAccess) canAccess() bool {
return a.partnerVisible != 0
}
func requirePeriodAccess(c *httpapi.Ctx) error {
if !currentPeriodAccess(c).canAccess() {
return c.Err(403, "对方已关闭姨姨可见权限")
}
return nil
}
func periodList(c *httpapi.Ctx) error {
access := currentPeriodAccess(c)
if !access.canAccess() {
return c.OK(emptyPeriod(access.visible))
}
selectionRequired, me, partner, err := periodSourceState(c)
if err != nil {
return err
}
if selectionRequired {
myPreview, err := periodSourcePreview(c.DB, me)
if err != nil {
return err
}
partnerPreview, err := periodSourcePreview(c.DB, partner)
if err != nil {
return err
}
return c.OK(M{"cycles": []any{}, "daily": []any{}, "sex_records": []any{},
"period_visible": access.visible, "can_access": true, "has_couple": true,
"selection_required": true, "my_user_id": me, "partner_user_id": partner,
"my_preview": myPreview, "partner_preview": partnerPreview})
}
// 姨姨在绑定后仍是一份情侣数据;未绑定时只读取自己的记录。
var where string
var args []any
if access.cid != 0 {
where = "couple_id = ?"
args = []any{access.cid}
} else {
where = "couple_id IS NULL AND user_id = ?"
args = []any{access.uid}
}
cycles, err := c.DB.Query("SELECT * FROM period_records WHERE "+where+" ORDER BY start_date DESC", args...)
if err != nil {
return err
}
daily, err := c.DB.Query("SELECT * FROM period_daily_records WHERE "+where+" ORDER BY record_date DESC", args...)
if err != nil {
return err
}
sex, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+where+" ORDER BY record_date DESC, record_time ASC", args...)
if err != nil {
return err
}
cycleRows := rows(cycles)
dailyRows := rows(daily)
// 经期结束以「最后一次标记有经期(period>0)的日期」为准:若周期记录的结束日期是预估的未来日期,
// 则按实际最后有经期日期收敛,让前端能正确显示「已结束」。
for _, cy := range cycleRows {
start, end := cy.Str("start_date"), cy.Str("end_date")
last := ""
for _, d := range dailyRows {
rd := d.Str("record_date")
if rd < start || rd > end {
continue
}
switch d.Str("period") {
case "1", "2", "3":
if rd > last {
last = rd
}
}
}
if last != "" && last < end {
cy["end_date"] = last
}
}
return c.OK(M{"cycles": cycleRows, "daily": dailyRows, "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0})
}
func periodAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
if !store.Truthy(startDate) || !store.Truthy(endDate) {
return c.Err(400, "请选择日期")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
return err
}
cycleLen, periodLen := int64(28), int64(5)
if c.InHas("cycle_length") {
cycleLen = c.InInt("cycle_length")
}
if c.InHas("period_length") {
periodLen = c.InInt("period_length")
}
recID, err := c.DB.Insert("INSERT INTO period_records (couple_id, user_id, start_date, end_date, cycle_length, period_length, symptoms, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
cid, uid, startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", ""))
if err != nil {
return err
}
// 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日)。
// 未来日期尚未确认经期量,不预先标记为有经期,避免把预估结束日误判为实际经期。
start, err := parseDate(startDate)
if err != nil {
return err
}
end, err := parseDate(endDate)
if err != nil {
return err
}
for d := start; !d.After(end) && !d.After(startOfToday()); d = d.AddDate(0, 0, 1) {
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, d.Format("2006-01-02")); err != nil {
return err
}
}
actID, err := logActivityErr(c, cid, uid, "period_add", "记录了经期("+startDate+" ~ "+endDate+")")
if err != nil {
return err
}
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "经期记录有更新",
"thing2": fmt.Sprintf("TA记录了经期(%d天),记得多关心哦 🌸", periodLen),
"date3": startDate,
"thing4": "点击查看详情",
}, "pages/period/period", actID, uid)
}
}
// PHP 返回的是 logActivity 之后的 lastInsertId(activity_log 的 id);Go 返回真实记录 id
return c.OK(M{"success": true, "id": fmt.Sprint(recID)})
}
// logActivityErr 与 logActivity 相同,但把 SQL 错误返回(对应 PHP 中会抛异常导致 500 的调用点)。
// 未绑定情侣(coupleID 为 nil)时跳过日志:activity_log.couple_id 为 NOT NULL,未绑定时无从归属。
func logActivityErr(c *httpapi.Ctx, coupleID any, userID int64, actionType, description string) (int64, error) {
if coupleID == nil {
return 0, nil
}
return c.DB.Insert("INSERT INTO activity_log (couple_id, user_id, action_type, description, detail, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
coupleID, userID, actionType, description, "")
}
func periodUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少记录ID")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("note"))); err != nil {
return err
}
cycleLen, periodLen := int64(28), int64(5)
if c.InHas("cycle_length") {
cycleLen = c.InInt("cycle_length")
}
if c.InHas("period_length") {
periodLen = c.InInt("period_length")
}
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
start, err := parseDate(startDate)
if err != nil {
return c.Err(400, "开始日期无效")
}
end, err := parseDate(endDate)
if err != nil || end.Before(start) {
return c.Err(400, "结束日期无效")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
oldArgs := append([]any{id}, condArgs...)
old, err := tx.QueryOne("SELECT start_date, end_date FROM period_records WHERE id = ? AND "+cond+" FOR UPDATE", oldArgs...)
if err != nil {
return err
}
if old == nil {
return c.Err(404, "周期记录不存在")
}
args := []any{startDate, endDate, cycleLen, periodLen, inOr(c, "symptoms", ""), inOr(c, "note", ""), id}
args = append(args, condArgs...)
if _, err := tx.Exec("UPDATE period_records SET start_date = ?, end_date = ?, cycle_length = ?, period_length = ?, symptoms = ?, note = ? WHERE id = ? AND "+cond, args...); err != nil {
return err
}
// 同步日历标记:去掉旧区间中不再属于任何周期的日期,再标记新区间。
oldStart, err := parseDate(old.Str("start_date"))
if err != nil {
return err
}
oldEnd, err := parseDate(old.Str("end_date"))
if err != nil {
return err
}
for d := oldStart; !d.After(oldEnd); d = d.AddDate(0, 0, 1) {
if !d.Before(start) && !d.After(end) {
continue
}
date := d.Format("2006-01-02")
checkArgs := append(append([]any{}, condArgs...), date, date)
other, err := tx.QueryOne("SELECT id FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ? LIMIT 1", checkArgs...)
if err != nil {
return err
}
if other == nil {
clearArgs := append(append([]any{}, condArgs...), date)
if _, err := tx.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date = ?", clearArgs...); err != nil {
return err
}
}
}
for d := start; !d.After(end) && !d.After(startOfToday()); d = d.AddDate(0, 0, 1) {
date := d.Format("2006-01-02")
if cid == nil {
// MySQL 的 UNIQUE 索引允许多个 NULL;未绑定记录必须按用户和日期查找。
existing, err := tx.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, date)
if err != nil {
return err
}
if existing != nil {
if _, err := tx.Exec("UPDATE period_daily_records SET period = '1' WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", uid, date); err != nil {
return err
}
continue
}
}
if _, err := tx.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period) VALUES (?, ?, ?, '1') ON DUPLICATE KEY UPDATE period = VALUES(period)", cid, uid, date); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return c.OK(M{"success": true})
}
func periodDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
startDate, endDate := c.InTrim("start_date"), c.InTrim("end_date")
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
if id != 0 {
delArgs := append([]any{id}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err
}
} else if store.Truthy(startDate) && store.Truthy(endDate) {
delArgs := append(append([]any{}, condArgs...), startDate, endDate)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE "+cond+" AND start_date = ? AND end_date = ?", delArgs...); err != nil {
return err
}
}
if store.Truthy(startDate) && store.Truthy(endDate) {
updArgs := append(append([]any{}, condArgs...), startDate, endDate)
if _, err := c.DB.Exec("UPDATE period_daily_records SET period = '0' WHERE "+cond+" AND record_date >= ? AND record_date <= ?", updArgs...); err != nil {
return err
}
}
if _, err := logActivityErr(c, cid, uid, "period_delete", "删除了经期周期记录"); err != nil {
return err
}
return c.OK(M{"success": true})
}
func periodVisibleGet(c *httpapi.Ctx) error {
access := currentPeriodAccess(c)
return c.OK(M{"period_visible": access.visible, "can_access": access.canAccess(), "has_couple": access.cid != 0})
}
func periodVisibleSet(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
visible := c.InInt("period_visible")
if visible != 0 && visible != 1 {
return c.Err(400, "可见权限只能为 0 或 1")
}
if _, err := c.DB.Exec("UPDATE users SET period_visible = ? WHERE id = ?", visible, c.UID()); err != nil {
return err
}
return c.OK(M{"success": true, "period_visible": visible})
}
func periodDailySave(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
recordDate := c.InTrim("record_date")
if !store.Truthy(recordDate) {
return c.Err(400, "请选择日期")
}
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("symptoms")+" "+c.InStr("mood")+" "+c.InStr("discharge")+" "+c.InStr("note"))); err != nil {
return err
}
// $input['temperature'] !== '' && !== null ? floatval(...) : null
numOrNil := func(key string) any {
v := c.In(key)
if v == nil || v == "" {
return nil
}
return store.ToFloat(v)
}
values := []any{store.ToStr(inOr(c, "period", "0")), store.ToStr(inOr(c, "sex", "0")),
inOr(c, "symptoms", ""), inOr(c, "mood", ""), inOr(c, "discharge", ""),
numOrNil("temperature"), numOrNil("weight"), inOr(c, "note", ""), inOr(c, "period_detail", "")}
if cid == nil {
// UNIQUE(record_date, couple_id) 对 NULL 不生效;按本人和日期更新已有行。
existing, err := c.DB.QueryOne("SELECT id FROM period_daily_records WHERE couple_id IS NULL AND user_id = ? AND record_date = ? LIMIT 1", uid, recordDate)
if err != nil {
return err
}
if existing != nil {
args := append(append([]any{}, values...), uid, recordDate)
if _, err := c.DB.Exec("UPDATE period_daily_records SET period = ?, sex = ?, symptoms = ?, mood = ?, discharge = ?, temperature = ?, weight = ?, note = ?, period_detail = ?, updated_at = NOW() WHERE couple_id IS NULL AND user_id = ? AND record_date = ?", args...); err != nil {
return err
}
} else {
args := append([]any{nil, uid, recordDate}, values...)
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", args...); err != nil {
return err
}
}
} else {
args := append([]any{cid, uid, recordDate}, values...)
if _, err := c.DB.Exec("INSERT INTO period_daily_records (couple_id, user_id, record_date, period, sex, symptoms, mood, discharge, temperature, weight, note, period_detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE period = VALUES(period), sex = VALUES(sex), symptoms = VALUES(symptoms), mood = VALUES(mood), discharge = VALUES(discharge), temperature = VALUES(temperature), weight = VALUES(weight), note = VALUES(note), period_detail = VALUES(period_detail), updated_at = NOW()", args...); err != nil {
return err
}
}
// 若本次将月经标记设为无,检查所属周期是否还有月经记录
np := inOr(c, "period", "0")
if np == "0" || np == int64(0) || np == "" {
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
cycArgs := append(append([]any{}, condArgs...), recordDate, recordDate)
cycles, err := c.DB.Query("SELECT id, start_date, end_date FROM period_records WHERE "+cond+" AND start_date <= ? AND end_date >= ?", cycArgs...)
if err != nil {
return err
}
for _, cy := range cycles {
cntArgs := append(append([]any{}, condArgs...), cy["start_date"], cy["end_date"])
cnt, err := c.DB.QueryOne("SELECT COUNT(*) as cnt FROM period_daily_records WHERE "+cond+" AND record_date >= ? AND record_date <= ? AND period > 0", cntArgs...)
if err != nil {
return err
}
if cnt != nil && cnt.Int("cnt") == 0 {
delArgs := append([]any{cy.Int("id")}, condArgs...)
if _, err := c.DB.Exec("DELETE FROM period_records WHERE id = ? AND "+cond, delArgs...); err != nil {
return err
}
}
}
}
return c.OK(M{"success": true})
}
func periodDailyGet(c *httpapi.Ctx) error {
cid := cidOrNil(c)
uid := c.UID()
date := strings.TrimSpace(c.Q("record_date"))
if !store.Truthy(date) {
return c.Err(400, "请选择日期")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
args := append(append([]any{}, condArgs...), date)
rec, err := c.DB.QueryOne("SELECT * FROM period_daily_records WHERE "+cond+" AND record_date = ?", args...)
if err != nil {
return err
}
if rec == nil {
return c.OK([]any{})
}
return c.OK(rec)
}
func periodSexAdd(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
recordDate := c.InTrim("record_date")
if !store.Truthy(recordDate) {
return c.Err(400, "请选择日期")
}
recordTime := c.InTrim("record_time")
measure := c.InTrim("measure")
measureCustom := c.InTrim("measure_custom")
if err := checkTextOr400(c, measureCustom); err != nil {
return err
}
var rt any
if store.Truthy(recordTime) {
rt = recordTime
}
recID, err := c.DB.Insert("INSERT INTO period_sex_records (couple_id, user_id, record_date, record_time, measure, measure_custom) VALUES (?, ?, ?, ?, ?, ?)",
cid, uid, recordDate, rt, measure, measureCustom)
if err != nil {
return err
}
desc := "记录了亲密行为"
if store.Truthy(recordTime) {
desc += "(" + recordDate + " " + recordTime + ")"
}
actID, err := logActivityErr(c, cid, uid, "period_sex_add", desc)
if err != nil {
return err
}
if partnerID := partnerUserId(c.DB, uid); partnerID != 0 {
if periodVisible(c, uid) != 0 {
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
"thing1": "亲密记录有更新", "thing2": "TA新增了一条亲密记录 💕", "date3": recordDate, "thing4": "点击查看详情",
}, "pages/period/period", actID, uid)
}
}
return c.OK(M{"success": true, "id": fmt.Sprint(recID)})
}
func periodSexDelete(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
cid := cidOrNil(c)
uid := c.UID()
id := c.InInt("id")
if id == 0 {
return c.Err(400, "缺少记录ID")
}
if cid == nil {
if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id IS NULL AND user_id = ?", id, uid); err != nil {
return err
}
} else {
if _, err := c.DB.Exec("DELETE FROM period_sex_records WHERE id = ? AND couple_id = ?", id, cid); err != nil {
return err
}
}
return c.OK(M{"success": true})
}
func periodSexList(c *httpapi.Ctx) error {
cid := cidOrNil(c)
uid := c.UID()
date := strings.TrimSpace(c.Q("record_date"))
if !store.Truthy(date) {
return c.Err(400, "请选择日期")
}
cond, condArgs := coupleCond(cid)
if cid == nil {
cond += " AND user_id = ?"
condArgs = append(condArgs, uid)
}
args := append(append([]any{}, condArgs...), date)
items, err := c.DB.Query("SELECT * FROM period_sex_records WHERE "+cond+" AND record_date = ? ORDER BY record_time ASC, id ASC", args...)
if err != nil {
return err
}
return c.OK(rows(items))
}