From bf99e529370ebd414efa60e13f580985a0e5659c Mon Sep 17 00:00:00 2001 From: CodeBuddy Date: Mon, 26 Oct 2026 23:07:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=98=E5=82=A8=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=80=81=E8=99=9A=E6=8B=9F=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E4=B8=8E=E5=AE=B9=E9=87=8F=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询 - 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order) - 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数 - 前端上传前容量预检,超容引导升级无限空间 - 我的页会员卡片展示「无限空间」与已用容量/到期时间 - 后台「存储空间管理」新增支付记录标签页 --- past-go/cmd/server/main.go | 8 + past-go/config.example.yaml | 9 + past-go/deploy/windows/README.md | 67 ++ past-go/deploy/windows/install-service.ps1 | 55 ++ past-go/internal/config/config.go | 12 + past-go/internal/handler/admin.go | 11 +- past-go/internal/handler/album.go | 15 + past-go/internal/handler/checkin.go | 1 + past-go/internal/handler/common.go | 8 + past-go/internal/handler/misc.go | 22 + past-go/internal/handler/period.go | 32 +- past-go/internal/handler/smoking.go | 24 +- .../internal/handler/smoking_stats_test.go | 1 + past-go/internal/handler/storage.go | 669 ++++++++++++++++++ past-go/internal/handler/user.go | 12 +- past-go/internal/httpapi/ctx.go | 13 + past-go/internal/httpapi/router.go | 4 +- past-go/internal/store/schema_sql.go | 24 + past-go/internal/virtualpay/virtualpay.go | 138 ++++ past-go/internal/wechat/subscribe_test.go | 3 - past-go/internal/wechat/wechat.go | 21 +- past-go/web/admin/src/App.tsx | 2 + past-go/web/admin/src/components/Layout.tsx | 3 +- past-go/web/admin/src/pages/Storage.tsx | 530 ++++++++++++++ past-go/web/admin/src/pages/Users.tsx | 18 +- weapp/app.json | 1 + weapp/pages/album/album.js | 35 +- weapp/pages/checkin-detail/checkin-detail.js | 10 +- weapp/pages/diary_edit/diary_edit.js | 18 +- weapp/pages/profile/profile.js | 114 ++- weapp/pages/profile/profile.wxml | 21 + weapp/pages/profile/profile.wxss | 18 + weapp/pages/publish/publish.js | 58 +- weapp/pages/settings/settings.js | 12 +- weapp/pages/storage/storage.js | 134 ++++ weapp/pages/storage/storage.json | 7 + weapp/pages/storage/storage.wxml | 52 ++ weapp/pages/storage/storage.wxss | 157 ++++ weapp/utils/request.js | 26 + weapp/utils/storage-guard.js | 43 ++ 部署验收清单.md | 87 +++ 41 files changed, 2387 insertions(+), 108 deletions(-) create mode 100644 past-go/deploy/windows/README.md create mode 100644 past-go/deploy/windows/install-service.ps1 create mode 100644 past-go/internal/handler/storage.go create mode 100644 past-go/internal/virtualpay/virtualpay.go create mode 100644 past-go/web/admin/src/pages/Storage.tsx create mode 100644 weapp/pages/storage/storage.js create mode 100644 weapp/pages/storage/storage.json create mode 100644 weapp/pages/storage/storage.wxml create mode 100644 weapp/pages/storage/storage.wxss create mode 100644 weapp/utils/storage-guard.js create mode 100644 部署验收清单.md diff --git a/past-go/cmd/server/main.go b/past-go/cmd/server/main.go index c10c425..3195a6b 100644 --- a/past-go/cmd/server/main.go +++ b/past-go/cmd/server/main.go @@ -131,6 +131,14 @@ func serve(cfgPath string, stop <-chan struct{}) { if cfg.NotifyIntervalMin > 0 { go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute) } + // 虚拟支付兜底查单:发货推送丢失/延迟时,通过 query_order 主动补发货。 + if cfg.VirtualPay.OfferID != "" { + interval := time.Duration(cfg.NotifyIntervalMin) * time.Minute + if interval <= 0 { + interval = 5 * time.Minute + } + go handler.RunStorageOrderQuery(db, wx, cfg, interval) + } srv := &http.Server{ Addr: cfg.Listen, diff --git a/past-go/config.example.yaml b/past-go/config.example.yaml index 1cfc2d4..0538329 100644 --- a/past-go/config.example.yaml +++ b/past-go/config.example.yaml @@ -16,3 +16,12 @@ tencent_map_key: "" max_upload_mb: 50 # 定时补发通知间隔(分钟),0 关闭 notify_interval_min: 5 +# 微信小程序虚拟支付(米大师虚拟支付2.0) +virtual_pay: + appid: wxdc40d0c51cf8fe1e # 小程序 AppID(默认复用 wechat.appid) + offer_id: "1450687270" # 支付应用 ID + app_key: "" # 正式环境 AppKey(上线后填写) + sandbox_key: "OVuTGBk28DKbvyKek7KrbDRyfZDKpk4P" # 沙箱环境 AppKey + env: 1 # 0-正式环境 1-沙箱环境 + notify_token: "2029807d6e6441149c138eee3254c27c" # 支付结果回调 Token(微信后台消息推送配置) + notify_aes_key: "" # 支付结果回调 EncodingAESKey(可选) diff --git a/past-go/deploy/windows/README.md b/past-go/deploy/windows/README.md new file mode 100644 index 0000000..50f42bb --- /dev/null +++ b/past-go/deploy/windows/README.md @@ -0,0 +1,67 @@ +# Windows (amd64) 部署说明 + +后端 Go 服务通过 `//go:embed` 把 React 管理后台(`web/admin/dist`)一并打进 `past-server.exe`, +是**单体可执行文件**:无需单独部署前端、无需安装 Node.js 运行环境,管理后台从 +`https://你的域名/backend/admin/` 直接访问。 + +## 部署包内容 + +``` +windows/ +├── past-server.exe # 单体服务(API + 管理后台) +├── config.yaml # 服务配置(由 config.example.yaml 复制,需填写真实值) +├── install-service.ps1 # 安装 / 卸载 Windows 服务脚本 +└── README.md +``` + +## 部署步骤 + +1. **准备数据库**:MySQL 建库 `past`、创建用户,确保服务所在机器可连接。 +2. **填写配置**:编辑 `config.yaml`(数据库、微信、虚拟支付等)。 + - 首次安装:复制 `config.example.yaml` 为 `config.yaml` 后填写。 + - **确认 `listen` 端口与 Nginx 转发端口一致**(`config.example.yaml` 示例为 `:8080`, + `../nginx-api.conf` 按 `127.0.0.1:8081` 转发,二者需保持一致)。 +3. **安装为 Windows 服务**(以管理员身份运行 PowerShell): + ```powershell + cd C:\path\to\windows + .\install-service.ps1 + ``` + 脚本会执行 `past-server.exe -service install -config <绝对路径>\config.yaml` 并启动服务, + 同时配置异常退出自动重启(3s / 3s / 10s)。 +4. **配置 Nginx**:把 `/api/`、`/backend/`、`/backend/uploads/` 转发到后端端口(参考 `../nginx-api.conf`), + 并配置已备案域名与 HTTPS 证书。 +5. **验证**: + - `https://你的域名/api` 返回 API 名称与版本; + - `https://你的域名/backend/admin/` 打开管理后台; + - 本机 `curl http://127.0.0.1:8081/healthz` 返回 `ok`。 + +## 服务管理 + +```powershell +.\install-service.ps1 -Status # 查看服务状态 +.\install-service.ps1 -Restart # 重启服务 +.\install-service.ps1 -Uninstall # 停止并卸载服务 +``` + +等价命令行(不经过脚本): + +```powershell +.\past-server.exe -service install -config C:\path\config.yaml +.\past-server.exe -service start +.\past-server.exe -service stop +.\past-server.exe -service uninstall +``` + +## 升级(替换新版本) + +1. 备份数据库与 `uploads/`、`data/` 目录; +2. `.\install-service.ps1 -Uninstall`(或先停止服务); +3. 用新的 `past-server.exe` 覆盖旧文件,**保留现有 `config.yaml`**(不要用 `config.example.yaml` 覆盖); +4. `.\install-service.ps1` 重新安装并启动。 + +## 说明 + +- 服务以 `LocalSystem` 身份运行,工作目录为 `System32`;程序已按「可执行文件所在目录」解析 + `config.yaml` 与 `data_dir`,因此部署目录可任意放置。 +- 数据库表结构由程序启动时自动迁移,无需手工执行 SQL。 +- 二进制内嵌 `tzdata`,Windows 无系统时区库也能正确使用 `Asia/Shanghai` 时区。 diff --git a/past-go/deploy/windows/install-service.ps1 b/past-go/deploy/windows/install-service.ps1 new file mode 100644 index 0000000..b7fed2f --- /dev/null +++ b/past-go/deploy/windows/install-service.ps1 @@ -0,0 +1,55 @@ +# past-server Windows service helper. +# Run this script from an ELEVATED PowerShell. +# +# Usage: +# .\install-service.ps1 install and start the service +# .\install-service.ps1 -Uninstall stop and remove the service +# .\install-service.ps1 -Restart restart the service +# .\install-service.ps1 -Status show service status + +param( + [switch]$Uninstall, + [switch]$Restart, + [switch]$Status +) + +$ErrorActionPreference = 'Stop' +$ServiceName = 'past-server' +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Exe = Join-Path $ScriptDir 'past-server.exe' +$Cfg = Join-Path $ScriptDir 'config.yaml' + +if (-not (Test-Path $Exe)) { + Write-Host "[ERROR] past-server.exe not found: $Exe" -ForegroundColor Red + exit 1 +} + +if ($Status) { + Get-Service -Name $ServiceName -ErrorAction SilentlyContinue | + Select-Object Name, Status, StartType | Format-Table -AutoSize + exit 0 +} + +if ($Uninstall) { + Write-Host "[INFO] Uninstalling service '$ServiceName' ..." -ForegroundColor Yellow + & $Exe -service uninstall + exit $LASTEXITCODE +} + +if ($Restart) { + Write-Host "[INFO] Restarting service '$ServiceName' ..." -ForegroundColor Yellow + & $Exe -service stop + Start-Sleep -Seconds 2 + & $Exe -service start + exit $LASTEXITCODE +} + +if (-not (Test-Path $Cfg)) { + Write-Host "[ERROR] config.yaml not found: $Cfg" -ForegroundColor Red + Write-Host "[INFO] Copy config.example.yaml to config.yaml and fill in DB / WeChat / virtual-pay settings first." -ForegroundColor Yellow + exit 1 +} + +Write-Host "[INFO] Installing service '$ServiceName' ..." -ForegroundColor Yellow +& $Exe -service install -config $Cfg +exit $LASTEXITCODE diff --git a/past-go/internal/config/config.go b/past-go/internal/config/config.go index ee99e4f..827f589 100644 --- a/past-go/internal/config/config.go +++ b/past-go/internal/config/config.go @@ -42,6 +42,17 @@ type Config struct { // Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭 NotifyIntervalMin int `yaml:"notify_interval_min"` + + // VirtualPay 微信小程序虚拟支付(米大师虚拟支付2.0)配置 + VirtualPay struct { + AppID string `yaml:"appid"` // 小程序 AppID(默认复用 wechat.appid) + OfferID string `yaml:"offer_id"` // 支付应用 ID + AppKey string `yaml:"app_key"` // 正式环境 AppKey + SandboxKey string `yaml:"sandbox_key"` // 沙箱环境 AppKey + Env int `yaml:"env"` // 0-正式环境 1-沙箱环境 + NotifyToken string `yaml:"notify_token"` // 支付结果回调 Token(微信后台消息推送配置) + NotifyAESKey string `yaml:"notify_aes_key"` // 支付结果回调 EncodingAESKey(可选,用于解密加密回调) + } `yaml:"virtual_pay"` } func Load(path string) (*Config, error) { @@ -55,6 +66,7 @@ func Load(path string) (*Config, error) { c.NotifyIntervalMin = 5 c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8" c.PHPCompat = "8.0" + c.VirtualPay.Env = 1 b, err := os.ReadFile(path) if err != nil { diff --git a/past-go/internal/handler/admin.go b/past-go/internal/handler/admin.go index d6d4861..57506b8 100644 --- a/past-go/internal/handler/admin.go +++ b/past-go/internal/handler/admin.go @@ -322,7 +322,16 @@ func adminUsers(c *httpapi.Ctx) error { if err != nil { return err } - return c.OK(paged(c, rs)) + pg := paged(c, rs) + items := pg["items"].([]store.Row) + for _, it := range items { + uid, cid := it.Int("id"), it.Int("couple_id") + it["used_bytes"] = storageUsedBytes(c, uid, cid) + it["storage_vip"] = isStorageVip(c, uid) + it["storage_vip_expire_at"] = storageVipExpireAt(c, uid) + } + pg["items"] = items + return c.OK(pg) } func adminFixLastLogin(c *httpapi.Ctx) error { diff --git a/past-go/internal/handler/album.go b/past-go/internal/handler/album.go index 8f9f5e5..f4898ab 100644 --- a/past-go/internal/handler/album.go +++ b/past-go/internal/handler/album.go @@ -165,6 +165,9 @@ func setAlbumCover(c *httpapi.Ctx) error { if fh == nil { return c.ErrCode(400, "未接收到文件", upload.ErrNoFileReceived) } + if err := CheckStorageQuota(c, fh.Size); err != nil { + return err + } res := up.Handle(fh, "", upload.CouplePath(spaceID, "photos")) if res.Path != "" { if _, err := c.DB.Exec("UPDATE albums SET cover = ? WHERE id = ?", res.Path, albumID); err != nil { @@ -239,6 +242,18 @@ func photoUpload(c *httpapi.Ctx) error { if len(files) == 0 { files = c.Files("file") } + // 存储配额检查(照片是存储大头,超限引导付费) + var pendingBytes int64 + if len(files) > 0 { + for _, fh := range files { + pendingBytes += fh.Size + } + } else { + pendingBytes = int64(len(c.Body())) + } + if err := CheckStorageQuota(c, pendingBytes); err != nil { + return err + } if len(files) == 0 { // 兼容某些安卓端直接传二进制:从原始请求体读取 raw := c.Body() diff --git a/past-go/internal/handler/checkin.go b/past-go/internal/handler/checkin.go index 226b39a..44f9e13 100644 --- a/past-go/internal/handler/checkin.go +++ b/past-go/internal/handler/checkin.go @@ -19,6 +19,7 @@ func registerCheckin(r *httpapi.Router) { r.Handle("checkin_delete", checkinDelete) r.Handle("smoking_info", smokingInfo) r.Handle("smoking_action", smokingAction) + r.Handle("smoking_card_visible", smokingCardVisible) } // weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一 diff --git a/past-go/internal/handler/common.go b/past-go/internal/handler/common.go index 0d0b895..b332da4 100644 --- a/past-go/internal/handler/common.go +++ b/past-go/internal/handler/common.go @@ -40,6 +40,7 @@ func Register(r *httpapi.Router, uploader *upload.Uploader) { registerSubscribe(r) registerPet(r) registerAdmin(r) + registerStorage(r) } func now() string { return time.Now().Format("2006-01-02 15:04:05") } @@ -55,6 +56,13 @@ func needLoginRecord(lastLoginAt string) bool { 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 { diff --git a/past-go/internal/handler/misc.go b/past-go/internal/handler/misc.go index 327389e..75828c8 100644 --- a/past-go/internal/handler/misc.go +++ b/past-go/internal/handler/misc.go @@ -46,6 +46,19 @@ func uploadGeneric(c *httpapi.Ctx) error { return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) } prefix := upload.UserFilePath(uid, subDir) + var pendingBytes int64 + for _, f := range []string{"file", "avatar", "photo", "image"} { + if fh := c.File(f); fh != nil { + pendingBytes = fh.Size + break + } + } + if pendingBytes == 0 { + pendingBytes = int64(len(c.Body())) + } + if err := CheckStorageQuota(c, pendingBytes); err != nil { + return err + } var res *upload.Result for _, f := range []string{"file", "avatar", "photo", "image"} { if fh := c.File(f); fh != nil { @@ -125,6 +138,15 @@ func setBgImage(c *httpapi.Ctx) error { if fh == nil { fh = c.File("file") } + var pendingBytes int64 + if fh != nil { + pendingBytes = fh.Size + } else { + pendingBytes = int64(len(c.Body())) + } + if err := CheckStorageQuota(c, pendingBytes); err != nil { + return err + } if fh != nil { version := nextVersion() return uploadResult(c, up.Handle(fh, "", couplePath), func(path string) error { return saveBg(path, version) }) diff --git a/past-go/internal/handler/period.go b/past-go/internal/handler/period.go index 7de60ff..57d61af 100644 --- a/past-go/internal/handler/period.go +++ b/past-go/internal/handler/period.go @@ -144,7 +144,30 @@ func periodList(c *httpapi.Ctx) error { if err != nil { return err } - return c.OK(M{"cycles": rows(cycles), "daily": rows(daily), "sex_records": rows(sex), "period_visible": access.visible, "can_access": true, "has_couple": access.cid != 0}) + 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 { @@ -172,7 +195,8 @@ func periodAdd(c *httpapi.Ctx) error { if err != nil { return err } - // 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日) + // 区间内每天创建 period_daily_records(对应 DatePeriod,含结束日)。 + // 未来日期尚未确认经期量,不预先标记为有经期,避免把预估结束日误判为实际经期。 start, err := parseDate(startDate) if err != nil { return err @@ -181,7 +205,7 @@ func periodAdd(c *httpapi.Ctx) error { if err != nil { return err } - for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + 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 } @@ -292,7 +316,7 @@ func periodUpdate(c *httpapi.Ctx) error { } } } - for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + 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;未绑定记录必须按用户和日期查找。 diff --git a/past-go/internal/handler/smoking.go b/past-go/internal/handler/smoking.go index ea9c9fd..50a7e98 100644 --- a/past-go/internal/handler/smoking.go +++ b/past-go/internal/handler/smoking.go @@ -55,7 +55,13 @@ func smokingDelta(kind string, quantity, stock int64) (int64, error) { } func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) { - result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "logs_total": int64(0), "logs_page": int64(1), "logs_has_more": false, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "available": true} + result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "logs_total": int64(0), "logs_page": int64(1), "logs_has_more": false, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "card_visible": int64(1), "available": true} + // 抽烟卡片显示/隐藏偏好:始终返回“当前登录用户”自己的设置(与查看对象无关),前端缓存失效后仍能恢复。 + if me, err := c.DB.QueryOne("SELECT smoking_card_visible FROM users WHERE id = ?", c.UID()); err != nil { + return nil, err + } else if me != nil { + result["card_visible"] = me.Int("smoking_card_visible") + } owner, err := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", uid) if err != nil { return nil, err @@ -130,6 +136,22 @@ func smokingInfo(c *httpapi.Ctx) error { return c.OK(info) } +// smokingCardVisible 保存当前用户对“抽烟卡片”的显示/隐藏偏好(1=显示 0=隐藏), +// 服务端持久化后,前端本地缓存失效时仍可恢复该设置。 +func smokingCardVisible(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + visible := c.InInt("visible") + if visible != 0 && visible != 1 { + return c.Err(400, "visible 必须为 0 或 1") + } + if _, err := c.DB.Exec("UPDATE users SET smoking_card_visible = ? WHERE id = ?", visible, c.UID()); err != nil { + return err + } + return c.OK(M{"success": true, "card_visible": visible}) +} + func smokingAction(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err diff --git a/past-go/internal/handler/smoking_stats_test.go b/past-go/internal/handler/smoking_stats_test.go index 9508a12..a10b503 100644 --- a/past-go/internal/handler/smoking_stats_test.go +++ b/past-go/internal/handler/smoking_stats_test.go @@ -17,6 +17,7 @@ func TestSmokingSnapshotWeeklyMonthlyStats(t *testing.T) { } steps := []testdb.Step{ uidStep(1), + {Match: "SELECT smoking_card_visible FROM users", Columns: []string{"smoking_card_visible"}, Rows: [][]driver.Value{{int64(1)}}}, {Match: "SELECT nickname, username", Columns: []string{"nickname", "username"}, Rows: [][]driver.Value{{"TA", "partner"}}}, {Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(10)}}}, {Match: "SELECT user1_id, user2_id FROM couples", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(2)}}}, diff --git a/past-go/internal/handler/storage.go b/past-go/internal/handler/storage.go new file mode 100644 index 0000000..ae81738 --- /dev/null +++ b/past-go/internal/handler/storage.go @@ -0,0 +1,669 @@ +package handler + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "past/internal/config" + "past/internal/httpapi" + "past/internal/store" + "past/internal/virtualpay" + "past/internal/wechat" +) + +const ( + settingFreeMB = "storage_free_mb" + settingMonthPrice = "storage_plan_month_price" + settingMonthProduct = "storage_plan_month_product" + settingYearPrice = "storage_plan_year_price" + settingYearProduct = "storage_plan_year_product" + settingPayMode = "storage_pay_mode" + settingPayOfferID = "storage_pay_offer_id" + settingPayAppKey = "storage_pay_app_key" + settingPaySandboxKey = "storage_pay_sandbox_key" + settingPayEnv = "storage_pay_env" +) + +const ( + defaultFreeMB int64 = 200 + defaultMonthPrice int64 = 500 + defaultYearPrice int64 = 2990 + defaultPayMode string = "short_series_goods" +) + +// ErrStorageQuotaExceeded 上传时存储超额的错误码,前端据此引导付费。 +const ErrStorageQuotaExceeded = 10001 + +func registerStorage(r *httpapi.Router) { + r.Handle("storage_info", storageInfo) + r.Handle("storage_create_order", storageOrderCreate) + r.Handle("storage_order_create", storageOrderCreate) + r.Handle("storage_pay_notify", storagePayNotify) + r.Handle("admin_storage_settings", adminStorageSettings) + r.Handle("admin_storage_users", adminStorageUsers) + r.Handle("admin_storage_user_update", adminStorageUserUpdate) + r.Handle("admin_storage_orders", adminStorageOrders) +} + +func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 { + row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) + if row == nil || row.IsNull("value") || row.Str("value") == "" { + return def + } + return row.Int("value") +} + +func storageSettingStr(c *httpapi.Ctx, key string, def string) string { + row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) + if row == nil || row.IsNull("value") { + return def + } + return row.Str("value") +} + +// storagePaySetting 读取支付通道配置:settings 中有非空值则使用,否则回退到 config.yaml 默认值。 +// 与 storageSettingStr 不同,空字符串在此视为“未设置”,避免后台清空后误覆盖 config 默认。 +func storagePaySetting(c *httpapi.Ctx, key, def string) string { + row, _ := c.DB.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) + if row == nil || row.IsNull("value") || row.Str("value") == "" { + return def + } + return row.Str("value") +} + +func vpayClient(c *httpapi.Ctx) *virtualpay.Client { + vp := c.App.Cfg.VirtualPay + offerID := storagePaySetting(c, settingPayOfferID, vp.OfferID) + appKey := storagePaySetting(c, settingPayAppKey, vp.AppKey) + sandboxKey := storagePaySetting(c, settingPaySandboxKey, vp.SandboxKey) + env := int(storageSettingInt(c, settingPayEnv, int64(vp.Env))) + return virtualpay.New(offerID, appKey, sandboxKey, env) +} + +func dirSize(root string) int64 { + var total int64 + _ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + total += info.Size() + return nil + }) + return total +} + +// storageUsedBytes 统计当前用户(或其情侣空间)已用存储字节数。 +// 组成:情侣空间共享文件(couples/{space})+ 相关用户(本人,或情侣双方)的头像(avatars/{user_id})与个人上传文件(users/{id})。 +func storageUsedBytes(c *httpapi.Ctx, uid, coupleID int64) int64 { + root := up.Dir + var total int64 + if coupleID != 0 { + if space := coupleSpaceID(c, coupleID); space != "" { + total += dirSize(filepath.Join(root, "couples", space)) + } + } + ids := []int64{uid} + if coupleID != 0 { + if cids := coupleUserIds(c, coupleID); len(cids) > 0 { + ids = cids + } + } + for _, id := range ids { + if u, _ := c.DB.QueryOne("SELECT user_id FROM users WHERE id = ?", id); u != nil { + total += dirSize(filepath.Join(root, "avatars", u.Str("user_id"))) + } + total += dirSize(filepath.Join(root, "users", store.ToStr(id))) + } + return total +} + +func storageVipUserIDs(c *httpapi.Ctx, uid int64) []int64 { + ids := []int64{uid} + if p := partnerUserId(c.DB, uid); p != 0 { + ids = append(ids, p) + } + return ids +} + +func isStorageVip(c *httpapi.Ctx, uid int64) bool { + for _, id := range storageVipUserIDs(c, uid) { + row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id) + if row != nil { + return true + } + } + return false +} + +func storageVipExpireAt(c *httpapi.Ctx, uid int64) string { + var latest string + for _, id := range storageVipUserIDs(c, uid) { + row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id) + if row != nil && row.Str("storage_vip_expire_at") > latest { + latest = row.Str("storage_vip_expire_at") + } + } + return latest +} + +// storageQuotaMB 返回用户实际可用的免费存储容量(MB): +// 优先使用后台手动设置的自定义容量,否则回退到全局默认免费额度。 +func storageQuotaMB(c *httpapi.Ctx, uid int64) int64 { + if uid != 0 { + if row, _ := c.DB.QueryOne("SELECT storage_quota_mb FROM users WHERE id = ?", uid); row != nil && row.Int("storage_quota_mb") > 0 { + return row.Int("storage_quota_mb") + } + } + return storageSettingInt(c, settingFreeMB, defaultFreeMB) +} + +// CheckStorageQuota 上传前检查存储配额;extraBytes 为本次待写入字节数。 +func CheckStorageQuota(c *httpapi.Ctx, extraBytes int64) error { + uid := c.UID() + if uid == 0 { + return nil + } + if isStorageVip(c, uid) { + return nil + } + freeBytes := storageQuotaMB(c, uid) * 1024 * 1024 + if storageUsedBytes(c, uid, c.CoupleID())+extraBytes > freeBytes { + return c.ErrCode(403, "存储空间不足,请升级为无限空间会员", ErrStorageQuotaExceeded) + } + return nil +} + +func storageInfo(c *httpapi.Ctx) error { + uid := c.UID() + if uid == 0 { + return c.Err(401, "请先登录") + } + coupleID := c.CoupleID() + freeMB := storageQuotaMB(c, uid) + vipExpire := storageVipExpireAt(c, uid) + plans := []M{ + {"key": "month", "name": "包月无限空间", "price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice), "product_id": storageSettingStr(c, settingMonthProduct, "")}, + {"key": "year", "name": "包年无限空间", "price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), "product_id": storageSettingStr(c, settingYearProduct, "")}, + } + resp := M{ + "used_bytes": storageUsedBytes(c, uid, coupleID), + "free_mb": freeMB, + "free_bytes": freeMB * 1024 * 1024, + "is_vip": isStorageVip(c, uid), + "vip_expire_at": nil, + "plans": plans, + } + if vipExpire != "" { + resp["vip_expire_at"] = vipExpire + } + return c.OK(resp) +} + +func storageOrderCreate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + uid := c.UID() + if uid == 0 { + return c.Err(401, "请先登录") + } + planKey := c.InTrim("plan_key") + var price int64 + var productID string + switch planKey { + case "month": + price = storageSettingInt(c, settingMonthPrice, defaultMonthPrice) + productID = storageSettingStr(c, settingMonthProduct, "") + case "year": + price = storageSettingInt(c, settingYearPrice, defaultYearPrice) + productID = storageSettingStr(c, settingYearProduct, "") + default: + return c.Err(400, "无效的套餐") + } + if productID == "" { + return c.Err(400, "该套餐暂未开放,请稍后再试") + } + user, _ := c.DB.QueryOne("SELECT session_key FROM users WHERE id = ?", uid) + if user == nil || user.Str("session_key") == "" { + return c.Err(400, "支付凭证已过期,请重新登录后重试") + } + vp := vpayClient(c) + if vp.OfferID == "" || vp.AppKey == "" { + return c.Err(500, "支付服务未配置,请联系管理员") + } + // outTradeNo 要求 8-32 位、不能以下划线开头;NewToken 为 32 位 hex,取前 31 位 + 前缀 S 共 32 位。 + outTradeNo := "S" + httpapi.NewToken()[:31] + attach, _ := json.Marshal(M{"uid": uid, "plan": planKey}) + signData, err := vp.BuildSignData(productID, int(price), outTradeNo, string(attach)) + if err != nil { + return c.Err(500, "生成支付参数失败") + } + var cid any + if cidv := c.CoupleID(); cidv != 0 { + cid = cidv + } + if _, err := c.DB.Insert("INSERT INTO storage_orders (user_id, couple_id, out_trade_no, plan_key, product_id, amount_cents, env, status, attach) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)", + uid, cid, outTradeNo, planKey, productID, price, vp.Env, string(attach)); err != nil { + return err + } + return c.OK(M{ + "out_trade_no": outTradeNo, + "mode": storageSettingStr(c, settingPayMode, defaultPayMode), + "sign_data": signData, + "pay_sig": vp.PaySig(signData), + "signature": virtualpay.SessionSignature(user.Str("session_key"), signData), + // wx.requestVirtualPayment 所需的独立参数(与 signData 一致,供前端直接组装) + "offer_id": vp.OfferID, + "buy_quantity": 1, + "env": vp.Env, + "currency_type": "CNY", + "product_id": productID, + "goods_price": price, + }) +} + +// xpayNotify 道具发货推送(xpay_goods_deliver_notify)的 XML 消息结构。 +type xpayNotify struct { + ToUserName string `xml:"ToUserName"` + FromUserName string `xml:"FromUserName"` + CreateTime string `xml:"CreateTime"` + MsgType string `xml:"MsgType"` + Event string `xml:"Event"` + OpenId string `xml:"OpenId"` + OutTradeNo string `xml:"OutTradeNo"` + Env string `xml:"Env"` + WeChatPayInfo struct { + MchOrderNo string `xml:"MchOrderNo"` + } `xml:"WeChatPayInfo"` + GoodsInfo struct { + ProductId string `xml:"ProductId"` + Quantity string `xml:"Quantity"` + } `xml:"GoodsInfo"` +} + +func storagePayNotify(c *httpapi.Ctx) error { + token := c.App.Cfg.VirtualPay.NotifyToken + // 消息推送 URL 有效性验证(微信在后台配置时发起 GET 请求) + if c.Method == "GET" { + if token != "" && !virtualpay.VerifyNotifySignature(token, c.Q("timestamp"), c.Q("nonce"), c.Q("signature")) { + return c.Err(403, "签名验证失败") + } + return c.Raw(200, "text/plain; charset=utf-8", []byte(c.Q("echostr"))) + } + // 发货推送验签(明文安全模式) + if token != "" && !virtualpay.VerifyNotifySignature(token, c.Q("timestamp"), c.Q("nonce"), c.Q("signature")) { + return c.Err(403, "签名验证失败") + } + n, err := parseXpayNotify(c.Body()) + if err != nil || n.Event != "xpay_goods_deliver_notify" { + // 无法解析或非道具发货事件(退款/投诉/风控等):忽略内容,但仍应答成功,避免微信无限重试 + return notifyReply(c, 0, "success") + } + if n.OutTradeNo == "" { + return notifyReply(c, 0, "success") + } + if err := deliverStorageOrder(c.DB, n.OutTradeNo, n.WeChatPayInfo.MchOrderNo); err != nil { + // 发货失败返回非 0,微信将按策略重试(最多 15 次) + return notifyReply(c, 1, "deliver failed") + } + return notifyReply(c, 0, "success") +} + +// parseXpayNotify 解析微信发货推送的 XML 消息体(明文模式)。 +func parseXpayNotify(body []byte) (*xpayNotify, error) { + var n xpayNotify + if err := xml.Unmarshal(body, &n); err != nil { + return nil, err + } + return &n, nil +} + +// notifyReply 输出微信消息推送要求的应答:推送为 XML 时,响应也必须为 XML。 +func notifyReply(c *httpapi.Ctx, errCode int, errMsg string) error { + body := "" + store.ToStr(errCode) + "" + return c.Raw(200, "application/xml; charset=utf-8", []byte(body)) +} + +// deliverStorageOrder 按订单号发货:标记支付成功并为用户开通/续期会员。 +// 注意:该函数不直接向客户端写响应,而是返回 error 供调用方统一应答,避免污染微信回调的 XML 响应。 +func deliverStorageOrder(db *store.DB, outTradeNo, wxOrderID string) error { + order, _ := db.QueryOne("SELECT id, user_id, plan_key, status FROM storage_orders WHERE out_trade_no = ?", outTradeNo) + if order == nil { + return fmt.Errorf("订单不存在: %s", outTradeNo) + } + if order.Str("status") == "success" { + return nil + } + if _, err := db.Exec("UPDATE storage_orders SET status = 'success', wx_order_id = ?, paid_at = NOW() WHERE id = ?", wxOrderID, order.Int("id")); err != nil { + return err + } + months := 1 + if order.Str("plan_key") == "year" { + months = 12 + } + _, err := db.Exec("UPDATE users SET storage_vip_expire_at = DATE_ADD(COALESCE(storage_vip_expire_at, NOW()), INTERVAL ? MONTH) WHERE id = ?", months, order.Int("user_id")) + return err +} + +// storageSettingIntDB / storagePaySettingDB:定时任务无请求上下文,直接以 DB 读取设置。 +func storageSettingIntDB(db *store.DB, key string, def int64) int64 { + row, _ := db.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) + if row == nil || row.IsNull("value") || row.Str("value") == "" { + return def + } + return row.Int("value") +} + +func storagePaySettingDB(db *store.DB, key, def string) string { + row, _ := db.QueryOne("SELECT `value` FROM settings WHERE `key` = ?", key) + if row == nil || row.IsNull("value") || row.Str("value") == "" { + return def + } + return row.Str("value") +} + +// RunStorageOrderQuery 兜底查单:定时扫描 pending 订单,通过 query_order 主动补发货。 +// 用于微信发货推送丢失/延迟时的兜底,保证已支付订单最终开通会员。 +func RunStorageOrderQuery(db *store.DB, wx *wechat.Client, cfg *config.Config, interval time.Duration) { + for { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("storage_order_query panic", "err", r) + } + }() + storageOrderQueryOnce(db, wx, cfg) + }() + time.Sleep(interval) + } +} + +func storageOrderQueryOnce(db *store.DB, wx *wechat.Client, cfg *config.Config) { + vpCfg := cfg.VirtualPay + offerID := storagePaySettingDB(db, settingPayOfferID, vpCfg.OfferID) + appKey := storagePaySettingDB(db, settingPayAppKey, vpCfg.AppKey) + sandboxKey := storagePaySettingDB(db, settingPaySandboxKey, vpCfg.SandboxKey) + env := int(storageSettingIntDB(db, settingPayEnv, int64(vpCfg.Env))) + vp := virtualpay.New(offerID, appKey, sandboxKey, env) + if vp.OfferID == "" || vp.AppKey == "" { + return + } + // 只补最近 24 小时内的 pending 订单;env 与当前配置一致才可查单。 + orders, err := db.Query("SELECT user_id, out_trade_no FROM storage_orders WHERE status = 'pending' AND env = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) ORDER BY id DESC LIMIT 50", env) + if err != nil || len(orders) == 0 { + return + } + tok := wx.AccessToken() + if tok == "" { + return + } + for _, o := range orders { + openid := userOpenId(db, o.Int("user_id")) + if openid == "" { + continue + } + outTradeNo := o.Str("out_trade_no") + res, err := vp.QueryOrder(tok, openid, outTradeNo) + if err != nil || res.ErrCode != 0 { + continue + } + // status:2=已支付待发货 3=发货中 4=已发货,均视为支付成功 + if res.Order.Status >= 2 && res.Order.Status <= 4 { + wxOrderID := res.Order.WxOrderID + if wxOrderID == "" { + wxOrderID = res.Order.OrderID + } + if err := deliverStorageOrder(db, outTradeNo, wxOrderID); err != nil { + slog.Error("storage_order_query 补发货失败", "out_trade_no", outTradeNo, "err", err) + } + } else if res.Order.Status == 6 { + // 订单已关闭,标记关闭不再重试 + _, _ = db.Exec("UPDATE storage_orders SET status = 'closed' WHERE out_trade_no = ? AND status = 'pending'", outTradeNo) + } + } +} + +func adminStorageSettings(c *httpapi.Ctx) error { + if c.Method == "GET" { + return c.OK(M{ + "free_mb": storageSettingInt(c, settingFreeMB, defaultFreeMB), + "month_price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice), + "month_product_id": storageSettingStr(c, settingMonthProduct, ""), + "year_price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), + "year_product_id": storageSettingStr(c, settingYearProduct, ""), + "pay_mode": storageSettingStr(c, settingPayMode, defaultPayMode), + "pay_env": int(storageSettingInt(c, settingPayEnv, int64(c.App.Cfg.VirtualPay.Env))), + "pay_offer_id": storagePaySetting(c, settingPayOfferID, c.App.Cfg.VirtualPay.OfferID), + "pay_app_key": storagePaySetting(c, settingPayAppKey, c.App.Cfg.VirtualPay.AppKey), + "pay_sandbox_key": storagePaySetting(c, settingPaySandboxKey, c.App.Cfg.VirtualPay.SandboxKey), + }) + } + if err := c.RequirePOST(); err != nil { + return err + } + pairs := []struct { + k string + v any + }{ + {settingFreeMB, c.InInt("free_mb")}, + {settingMonthPrice, c.InInt("month_price_cents")}, + {settingMonthProduct, strings.TrimSpace(c.InStr("month_product_id"))}, + {settingYearPrice, c.InInt("year_price_cents")}, + {settingYearProduct, strings.TrimSpace(c.InStr("year_product_id"))}, + {settingPayMode, strings.TrimSpace(c.InStr("pay_mode"))}, + {settingPayOfferID, strings.TrimSpace(c.InStr("pay_offer_id"))}, + {settingPayAppKey, strings.TrimSpace(c.InStr("pay_app_key"))}, + {settingPaySandboxKey, strings.TrimSpace(c.InStr("pay_sandbox_key"))}, + {settingPayEnv, c.InInt("pay_env")}, + } + for _, p := range pairs { + if err := setSetting(c, p.k, p.v); err != nil { + return err + } + } + return c.OK(M{"success": true}) +} + +// storagePlanLabel 套餐中文名(后台展示用) +func storagePlanLabel(plan string) string { + switch plan { + case "month": + return "包月无限空间" + case "year": + return "包年无限空间" + } + return "" +} + +// adminStorageUsers 存储用户管理:拉取全部用户,计算已用空间,支持搜索/筛选/排序。 +// sort: used_desc(默认,按已用空间从大到小)/ used_asc / expire / newest / last_login +func adminStorageUsers(c *httpapi.Ctx) error { + search := strings.TrimSpace(c.Q("search")) + vip := strings.TrimSpace(c.Q("vip")) + if !c.QHas("vip") { + vip = "all" + } + sortKey := strings.TrimSpace(c.Q("sort")) + if !c.QHas("sort") { + sortKey = "used_desc" + } + sql := "SELECT id, user_id, username, nickname, avatar, gender, couple_id, created_at, last_login_at, storage_quota_mb, storage_plan, storage_plan_started_at, storage_vip_expire_at FROM users" + var where []string + var params []any + if vip == "vip" { + where = append(where, "storage_vip_expire_at > NOW()") + } else if vip == "free" { + where = append(where, "(storage_vip_expire_at IS NULL OR storage_vip_expire_at <= NOW())") + } + if search != "" { + like := "%" + search + "%" + if isNumeric(search) { + where = append(where, "(id = ? OR user_id = ? OR nickname LIKE ? OR username LIKE ?)") + params = append(params, store.ToInt(search), store.ToInt(search), like, like) + } else { + where = append(where, "(nickname LIKE ? OR username LIKE ?)") + params = append(params, like, like) + } + } + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + rs, err := c.DB.Query(sql, params...) + if err != nil { + return err + } + today := now() + out := make([]store.Row, 0, len(rs)) + for _, u := range rs { + uid, cid := u.Int("id"), u.Int("couple_id") + expire := u.Str("storage_vip_expire_at") + quota := u.Int("storage_quota_mb") + u["used_bytes"] = storageUsedBytes(c, uid, cid) + u["storage_vip"] = expire != "" && expire > today + if quota <= 0 { + quota = storageSettingInt(c, settingFreeMB, defaultFreeMB) + } + u["free_mb"] = quota + u["plan_label"] = storagePlanLabel(u.Str("storage_plan")) + out = append(out, u) + } + switch sortKey { + case "used_asc": + sort.Slice(out, func(i, j int) bool { return out[i].Int("used_bytes") < out[j].Int("used_bytes") }) + case "expire": + sort.Slice(out, func(i, j int) bool { + ie, je := out[i].Str("storage_vip_expire_at"), out[j].Str("storage_vip_expire_at") + if ie == je { + return out[i].Int("used_bytes") > out[j].Int("used_bytes") + } + return ie > je + }) + case "last_login": + sort.Slice(out, func(i, j int) bool { return out[i].Str("last_login_at") > out[j].Str("last_login_at") }) + case "newest": + sort.Slice(out, func(i, j int) bool { return out[i].Str("created_at") > out[j].Str("created_at") }) + default: // used_desc + sort.Slice(out, func(i, j int) bool { return out[i].Int("used_bytes") > out[j].Int("used_bytes") }) + } + return c.OK(paged(c, out)) +} + +// adminStorageUserUpdate 手动修改用户存储容量 / 套餐 / 到期时间。 +func adminStorageUserUpdate(c *httpapi.Ctx) error { + if err := c.RequirePOST(); err != nil { + return err + } + userID := c.InInt("user_id") + if userID <= 0 { + return c.Err(400, "用户ID无效") + } + user, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ?", userID) + if user == nil { + return c.Err(404, "用户不存在") + } + // 1) 自定义容量(0 = 恢复默认免费额度) + if c.InHas("quota_mb") { + quota := c.InInt("quota_mb") + if quota < 0 { + quota = 0 + } + if _, err := c.DB.Exec("UPDATE users SET storage_quota_mb = ? WHERE id = ?", quota, userID); err != nil { + return err + } + } + // 2) 套餐 / 到期时间 + if c.InHas("plan_key") { + planKey := c.InTrim("plan_key") + switch planKey { + case "month", "year": + months := 1 + if planKey == "year" { + months = 12 + } + expire := c.InTrim("vip_expire_at") + if expire == "" { + if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = DATE_ADD(COALESCE(storage_vip_expire_at, NOW()), INTERVAL ? MONTH) WHERE id = ?", planKey, months, userID); err != nil { + return err + } + } else { + if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = ? WHERE id = ?", planKey, expire, userID); err != nil { + return err + } + } + case "free": + if _, err := c.DB.Exec("UPDATE users SET storage_plan = '', storage_plan_started_at = NULL, storage_vip_expire_at = NULL WHERE id = ?", userID); err != nil { + return err + } + default: + return c.Err(400, "无效的套餐") + } + } else if c.InHas("vip_expire_at") { + expire := c.InTrim("vip_expire_at") + if expire == "" { + if _, err := c.DB.Exec("UPDATE users SET storage_vip_expire_at = NULL WHERE id = ?", userID); err != nil { + return err + } + } else { + if _, err := c.DB.Exec("UPDATE users SET storage_vip_expire_at = ? WHERE id = ?", expire, userID); err != nil { + return err + } + } + } + return c.OK(M{"success": true}) +} + +// envLabel 支付环境中文名。 +func envLabel(env int64) string { + if env == 0 { + return "正式" + } + return "沙箱" +} + +// adminStorageOrders 存储支付记录查询:支持按订单号/用户ID/昵称搜索、按状态筛选、分页。 +func adminStorageOrders(c *httpapi.Ctx) error { + search := strings.TrimSpace(c.Q("search")) + status := strings.TrimSpace(c.Q("status")) + if status == "" { + status = "all" + } + var where []string + var params []any + if status != "all" { + where = append(where, "o.status = ?") + params = append(params, status) + } + if search != "" { + if isNumeric(search) { + where = append(where, "(o.out_trade_no = ? OR o.user_id = ? OR u.user_id = ?)") + params = append(params, search, store.ToInt(search), store.ToInt(search)) + } else { + like := "%" + search + "%" + where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)") + params = append(params, like, like) + } + } + sql := `SELECT o.id, o.user_id AS uid, u.user_id AS display_id, u.nickname, u.username, + o.out_trade_no, o.plan_key, o.product_id, o.amount_cents, o.env, o.status, o.wx_order_id, o.created_at, o.paid_at + FROM storage_orders o LEFT JOIN users u ON u.id = o.user_id` + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + sql += " ORDER BY o.id DESC" + rs, err := c.DB.Query(sql, params...) + if err != nil { + return err + } + for _, o := range rs { + o["plan_label"] = storagePlanLabel(o.Str("plan_key")) + o["env_label"] = envLabel(o.Int("env")) + } + return c.OK(paged(c, rs)) +} diff --git a/past-go/internal/handler/user.go b/past-go/internal/handler/user.go index c1d3186..1924442 100644 --- a/past-go/internal/handler/user.go +++ b/past-go/internal/handler/user.go @@ -243,7 +243,7 @@ func wechatLogin(c *httpapi.Ctx) error { if code == "" { return c.Err(400, "缺少登录凭证") } - openid, wxErr, available := c.App.Wechat.Jscode2Session(code) + openid, sessionKey, wxErr, available := c.App.Wechat.Jscode2Session(code) if !available { return c.Err(500, "微信登录服务暂不可用") } @@ -261,7 +261,11 @@ func wechatLogin(c *httpapi.Ctx) error { uid := user.Int("id") token := httpapi.NewToken() c.SaveUserToken(uid, token) - c.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = ?", uid) + 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) @@ -300,8 +304,8 @@ func wechatLogin(c *httpapi.Ctx) error { } region := c.InTrim("region") defaultNick := "用户" + last4(displayID) - newID, err := c.DB.Insert("INSERT INTO users (user_id, openid, username, password, nickname, region) VALUES (?, ?, ?, ?, ?, ?)", - displayID, openid, "wx_"+openid, hashPassword(randomHex(8)), defaultNick, region) + 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 } diff --git a/past-go/internal/httpapi/ctx.go b/past-go/internal/httpapi/ctx.go index b0b8f84..049ba65 100644 --- a/past-go/internal/httpapi/ctx.go +++ b/past-go/internal/httpapi/ctx.go @@ -108,6 +108,19 @@ func (c *Ctx) ErrCode(code int, msg string, errCode int) error { return c.JSON(code, M{"error": msg, "code": errCode}) } +// Raw 输出原始响应(非 JSON),用于微信回调验签等需要纯文本响应的场景。 +func (c *Ctx) Raw(code int, contentType string, data []byte) error { + if c.responded { + return errResponded + } + c.responded = true + if contentType == "" { + contentType = "text/plain; charset=utf-8" + } + c.G.Data(code, contentType, data) + return errResponded +} + // M 便捷的 JSON 对象类型 type M = map[string]any diff --git a/past-go/internal/httpapi/router.go b/past-go/internal/httpapi/router.go index 70ab754..51353f9 100644 --- a/past-go/internal/httpapi/router.go +++ b/past-go/internal/httpapi/router.go @@ -52,9 +52,9 @@ func (r *Router) Actions() []string { // 与 index.php 中 $publicActions / $adminActions 完全一致 var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login", - "check_invite_code", "register_by_invite", "check_auth") + "check_invite_code", "register_by_invite", "check_auth", "storage_pay_notify") -var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs") +var adminActions = set("admin_login", "admin_check_auth", "admin_logout", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_change_password", "admin_change_username", "admin_reset_user_password", "admin_update_user", "admin_delete_user", "admin_delete_content", "admin_activity_log", "admin_get_global_bg", "admin_set_global_bg", "admin_clear_global_bg", "admin_couple_bg_list", "admin_clear_couple_bg", "admin_contents", "admin_fix_last_login", "admin_unbind_requests", "admin_force_unbind", "admin_restore_couple", "admin_delete_couple_data", "admin_pets", "admin_pet_detail", "admin_clean_locations", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_user_update", "admin_storage_orders") func set(items ...string) map[string]bool { m := make(map[string]bool, len(items)) diff --git a/past-go/internal/store/schema_sql.go b/past-go/internal/store/schema_sql.go index 222aac1..c75206e 100644 --- a/past-go/internal/store/schema_sql.go +++ b/past-go/internal/store/schema_sql.go @@ -427,6 +427,24 @@ var createTables = []string{ FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS storage_orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + couple_id INT DEFAULT NULL, + out_trade_no VARCHAR(64) NOT NULL UNIQUE, + plan_key VARCHAR(20) NOT NULL COMMENT 'month/year', + product_id VARCHAR(64) NOT NULL DEFAULT '', + amount_cents INT NOT NULL DEFAULT 0, + env TINYINT NOT NULL DEFAULT 1 COMMENT '0-正式 1-沙箱', + status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/success/closed', + wx_order_id VARCHAR(64) NOT NULL DEFAULT '', + attach TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + paid_at DATETIME DEFAULT NULL, + INDEX idx_user (user_id), + INDEX idx_status (status, created_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, } // addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。 @@ -487,4 +505,10 @@ var addColumns = []addColumn{ {"timeline", "repeat_unit", "VARCHAR(10) DEFAULT '' AFTER repeat_interval"}, {"timeline", "bg_image", "VARCHAR(500) DEFAULT '' AFTER repeat_unit"}, {"couples", "data_deleted", "TINYINT(1) DEFAULT 0 COMMENT '0=数据保留,1=数据已删除'"}, + {"users", "session_key", "VARCHAR(128) DEFAULT '' COMMENT '微信会话密钥,用于虚拟支付签名'"}, + {"users", "storage_vip_expire_at", "DATETIME DEFAULT NULL COMMENT '存储会员到期时间,NULL=未开通'"}, + {"users", "storage_quota_mb", "INT DEFAULT 0 COMMENT '自定义存储容量MB,0=使用默认免费额度'"}, + {"users", "storage_plan", "VARCHAR(20) DEFAULT '' COMMENT '当前存储套餐 month/year/空=未开通'"}, + {"users", "storage_plan_started_at", "DATETIME DEFAULT NULL COMMENT '存储套餐开通时间'"}, + {"users", "smoking_card_visible", "TINYINT NOT NULL DEFAULT 1 COMMENT '抽烟卡片是否显示:1显示 0隐藏'"}, } diff --git a/past-go/internal/virtualpay/virtualpay.go b/past-go/internal/virtualpay/virtualpay.go new file mode 100644 index 0000000..0596484 --- /dev/null +++ b/past-go/internal/virtualpay/virtualpay.go @@ -0,0 +1,138 @@ +// Package virtualpay 实现微信小程序虚拟支付(米大师虚拟支付2.0)的道具直购签名与参数构造。 +// 参考微信文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/midas-payment/ +package virtualpay + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// Client 虚拟支付客户端配置。AppKey 由 New 根据环境自动选择(沙箱/正式)。 +type Client struct { + OfferID string // 支付应用 ID(OfferID) + AppKey string // 当前环境使用的 AppKey + Env int // 0-正式环境 1-沙箱环境 +} + +// New 构造虚拟支付客户端。env=1 使用沙箱 AppKey,否则使用正式 AppKey。 +func New(offerID, appKey, sandboxKey string, env int) *Client { + key := appKey + if env == 1 { + key = sandboxKey + } + return &Client{OfferID: offerID, AppKey: key, Env: env} +} + +// SignData 道具直购(wx.requestVirtualPayment)的 signData 结构。 +// 字段名与微信虚拟支付2.0保持一致;goodsPrice 单位为「分」。 +type SignData struct { + OfferID string `json:"offerId"` + BuyQuantity int `json:"buyQuantity"` + Env int `json:"env"` + CurrencyType string `json:"currencyType"` + ProductID string `json:"productId"` + GoodsPrice int `json:"goodsPrice"` + OutTradeNo string `json:"outTradeNo"` + Attach string `json:"attach,omitempty"` +} + +// BuildSignData 构造 signData JSON 字符串。 +func (c *Client) BuildSignData(productID string, goodsPrice int, outTradeNo, attach string) (string, error) { + sd := SignData{ + OfferID: c.OfferID, + BuyQuantity: 1, + Env: c.Env, + CurrencyType: "CNY", + ProductID: productID, + GoodsPrice: goodsPrice, + OutTradeNo: outTradeNo, + Attach: attach, + } + b, err := json.Marshal(sd) + if err != nil { + return "", err + } + return string(b), nil +} + +// PaySig 支付签名:HMAC-SHA256(appKey, "requestVirtualPayment&" + signData) 的十六进制小写。 +func (c *Client) PaySig(signData string) string { + return hmacSHA256Hex(c.AppKey, "requestVirtualPayment&"+signData) +} + +// SessionSignature 会话签名:HMAC-SHA256(sessionKey, signData) 的十六进制小写。 +func SessionSignature(sessionKey, signData string) string { + return hmacSHA256Hex(sessionKey, signData) +} + +func hmacSHA256Hex(key, data string) string { + h := hmac.New(sha256.New, []byte(key)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +// QueryOrderResult 服务端 query_order 接口的响应。 +type QueryOrderResult struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + Order struct { + OrderID string `json:"order_id"` + Status int `json:"status"` // 2=已支付待发货 3=发货中 4=已发货 + PaidFee int `json:"paid_fee"` + PaidTime int64 `json:"paid_time"` + WxOrderID string `json:"wx_order_id"` + } `json:"order"` +} + +// QueryOrder 服务端主动查单(现金单)。用于发货推送丢失时兜底补发货。 +// 签名:pay_sig = HMAC-SHA256(AppKey, "/xpay/query_order&" + postBody)。 +func (c *Client) QueryOrder(accessToken, openid, orderID string) (*QueryOrderResult, error) { + body, err := json.Marshal(map[string]any{ + "openid": openid, + "env": c.Env, + "order_id": orderID, + }) + if err != nil { + return nil, err + } + uri := "/xpay/query_order" + paySig := hmacSHA256Hex(c.AppKey, uri+"&"+string(body)) + u := "https://api.weixin.qq.com/xpay/query_order?access_token=" + accessToken + "&pay_sig=" + paySig + + cl := &http.Client{Timeout: 10 * time.Second} + resp, err := cl.Post(u, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + var out QueryOrderResult + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return &out, nil +} + +// VerifyNotifySignature 校验微信消息推送回调的 signature。 +// 微信规则:signature = sha1( sort(token, timestamp, nonce) )。 +func VerifyNotifySignature(token, timestamp, nonce, signature string) bool { + if signature == "" || token == "" { + return false + } + parts := []string{token, timestamp, nonce} + sort.Strings(parts) + sum := sha1.Sum([]byte(strings.Join(parts, ""))) + return hex.EncodeToString(sum[:]) == strings.ToLower(signature) +} diff --git a/past-go/internal/wechat/subscribe_test.go b/past-go/internal/wechat/subscribe_test.go index e50b19f..1ab4f01 100644 --- a/past-go/internal/wechat/subscribe_test.go +++ b/past-go/internal/wechat/subscribe_test.go @@ -43,9 +43,6 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) { } steps = append(steps, testdb.Step{Match: "FROM subscribe_templates", Args: []any{int64(2), "notify"}, Columns: []string{"id"}, Rows: auth}) } - if tt.code == int64(43101) { - steps = append(steps, testdb.Step{Match: "DELETE FROM subscribe_templates"}) - } meta := []testdb.Step{{Match: "SELECT couple_id FROM users", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(5)}}}, {Match: "SELECT action_type FROM activity_log", Columns: []string{"action_type"}, Rows: [][]driver.Value{{"diary_create"}}}} steps = append(steps, meta...) steps = append(steps, testdb.Step{Match: "INSERT INTO subscribe_message_logs", Check: func(q string, args []any) error { diff --git a/past-go/internal/wechat/wechat.go b/past-go/internal/wechat/wechat.go index 55e68de..89684fe 100644 --- a/past-go/internal/wechat/wechat.go +++ b/past-go/internal/wechat/wechat.go @@ -160,30 +160,31 @@ func (c *Client) CheckImage(filePath, openid string) (bool, string) { return true, "" } -// Jscode2Session 通过 code 换取 openid。返回 (openid, errmsg, ok);ok=false 且 errmsg 为空表示服务不可用。 -func (c *Client) Jscode2Session(code string) (openid string, wxErr string, available bool) { +// Jscode2Session 通过 code 换取 openid 与 session_key。 +// 返回 (openid, sessionKey, errmsg, ok);ok=false 且 errmsg 为空表示服务不可用。 +func (c *Client) Jscode2Session(code string) (openid string, sessionKey string, wxErr string, available bool) { u := "https://api.weixin.qq.com/sns/jscode2session?appid=" + c.AppID + "&secret=" + c.Secret + "&js_code=" + url.QueryEscape(code) + "&grant_type=authorization_code" resp, err := c.http.Get(u) if err != nil { - return "", "", false + return "", "", "", false } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) if len(b) == 0 { - return "", "", false + return "", "", "", false } var data map[string]any if err := json.Unmarshal(b, &data); err != nil { - return "", "", true + return "", "", "", true } if ec, ok := data["errcode"]; ok && store.ToInt(ec) != 0 { msg := store.ToStr(data["errmsg"]) if msg == "" { msg = "未知错误" } - return "", msg, true + return "", "", msg, true } - return store.ToStr(data["openid"]), "", true + return store.ToStr(data["openid"]), store.ToStr(data["session_key"]), "", true } // SendSubscribeMessage 对应 sendSubscribeMessage()。 @@ -307,9 +308,9 @@ func (c *Client) SendSubscribeMessage(toUserID int64, templateType string, data c.clearTokenCache() continue } - if ec == 40003 || ec == 43101 { - c.DB.Exec("DELETE FROM subscribe_templates WHERE user_id = ? AND template_type = ?", toUserID, templateType) - } + // 即使微信返回“用户拒收/一次性订阅次数用尽”(40003/43101),也保留 subscribe_templates 记录: + // 这样 my_subscribe_templates 始终返回“已订阅”,前端不会因此重复弹出订阅授权框, + // 满足“用户只需订阅一次、之后不再重复勾选”。发送失败仅记录日志,由前端在操作前静默续订补足次数。 break } return false diff --git a/past-go/web/admin/src/App.tsx b/past-go/web/admin/src/App.tsx index d38f38f..752fa72 100644 --- a/past-go/web/admin/src/App.tsx +++ b/past-go/web/admin/src/App.tsx @@ -12,6 +12,7 @@ import UnbindPage from "@/pages/Unbind" import PetsPage from "@/pages/Pets" import SubscribeMessagesPage from "@/pages/SubscribeMessages" import SettingsPage from "@/pages/Settings" +import StoragePage from "@/pages/Storage" // 登录态:admin_check_auth(Cookie 会话) export function useAuth() { @@ -45,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/past-go/web/admin/src/components/Layout.tsx b/past-go/web/admin/src/components/Layout.tsx index e956f3c..74acf78 100644 --- a/past-go/web/admin/src/components/Layout.tsx +++ b/past-go/web/admin/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom" import { useQueryClient, useIsFetching } from "@tanstack/react-query" -import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight, Bell } from "lucide-react" +import { BarChart3, Users, Heart, FileText, Image, Unlink, PawPrint, Settings, LogOut, Menu, RefreshCw, ChevronRight, Bell, HardDrive } from "lucide-react" import { toast } from "sonner" import { api } from "@/lib/api" import { cn } from "@/lib/utils" @@ -16,6 +16,7 @@ const NAV = [ { to: "/unbind", label: "解绑管理", icon: Unlink, group: "空间管理" }, { to: "/pets", label: "宠物管理", icon: PawPrint, group: "空间管理" }, { to: "/backgrounds", label: "背景管理", icon: Image, group: "系统" }, + { to: "/storage", label: "存储空间", icon: HardDrive, group: "系统" }, { to: "/settings", label: "系统设置", icon: Settings, group: "系统" }, { to: "/subscribe-messages", label: "订阅消息", icon: Bell, group: "系统" }, ] diff --git a/past-go/web/admin/src/pages/Storage.tsx b/past-go/web/admin/src/pages/Storage.tsx new file mode 100644 index 0000000..1f903fd --- /dev/null +++ b/past-go/web/admin/src/pages/Storage.tsx @@ -0,0 +1,530 @@ +import { useEffect, useState } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { Search, X, Crown } from "lucide-react" +import { toast } from "sonner" +import { api, fmtTime } from "@/lib/api" +import { Avatar } from "@/components/ImagePreview" +import { PageTitle, Empty, Loading, ErrorBox } from "@/components/common" +import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +function formatBytes(n?: number): string { + if (!n || n <= 0) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB"] + let i = 0 + let v = n + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ } + return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}` +} + +interface StorageUser { + id: number + user_id: string + username: string + nickname: string + avatar: string + gender: string + couple_id: number + created_at: string + last_login_at: string + used_bytes: number + free_mb: number + storage_quota_mb: number + storage_vip: boolean + storage_plan: string + plan_label: string + storage_plan_started_at: string + storage_vip_expire_at: string +} + +const toDateTimeLocal = (s?: string | null): string => { + if (!s) return "" + return String(s).replace(" ", "T").slice(0, 16) +} + +const fromDateTimeLocal = (s: string): string => { + if (!s) return "" + return s.replace("T", " ") + ":00" +} + +function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; onClose: () => void }) { + const qc = useQueryClient() + const [quota, setQuota] = useState("") + const [plan, setPlan] = useState("free") + const [expire, setExpire] = useState("") + + useEffect(() => { + if (!user) return + setQuota(user.storage_quota_mb > 0 ? String(user.storage_quota_mb) : "") + setPlan(user.storage_plan || "free") + setExpire(toDateTimeLocal(user.storage_vip_expire_at)) + }, [user]) + + async function save() { + if (!user) return + try { + await api("adminStorageUserUpdate", { + user_id: user.id, + quota_mb: parseInt(quota || "0", 10) || 0, + plan_key: plan, + vip_expire_at: plan === "free" ? undefined : fromDateTimeLocal(expire), + }) + toast.success("存储设置已更新") + qc.invalidateQueries({ queryKey: ["admin_storage_users"] }) + onClose() + } catch (e) { + toast.error((e as Error).message) + } + } + + const name = user?.nickname || user?.username || "-" + return ( + !o && onClose()}> + + + 💾 修改存储空间 + 为「{name}」调整存储容量与会员套餐 + + {user && ( +
+
+ +
+
{name}#{user.user_id || "-"}
+
已用 {formatBytes(user.used_bytes)}
+
+
+ +
+ + setQuota(e.target.value)} placeholder="留空或 0 = 使用默认免费额度" /> +

仅对非会员生效,设置后覆盖全局默认免费额度。

+
+ +
+ + +
+ + {plan !== "free" && ( +
+ + setExpire(e.target.value)} /> +

包月自动加 1 个月、包年自动加 12 个月;也可手动指定到期时间。

+
+ )} + +
+ + +
+
+ )} +
+
+ ) +} + +interface StorageSettingsResp { + free_mb: number + month_price_cents: number + month_product_id: string + year_price_cents: number + year_product_id: string + pay_mode: string + pay_env: number + pay_offer_id: string + pay_app_key: string + pay_sandbox_key: string +} + +interface StorageOrder { + id: number + uid: number + display_id: string + nickname: string + username: string + out_trade_no: string + plan_key: string + plan_label: string + product_id: string + amount_cents: number + env: number + env_label: string + status: string + wx_order_id: string + created_at: string + paid_at: string +} + +function formatAmount(cents?: number): string { + return `¥${((cents || 0) / 100).toFixed(2)}` +} + +function orderStatusBadge(status: string) { + switch (status) { + case "success": return 已支付 + case "pending": return 待支付 + case "closed": return 已关闭 + default: return {status} + } +} + +export default function StoragePage() { + // 用户管理 + const [input, setInput] = useState("") + const [search, setSearch] = useState("") + const [vip, setVip] = useState("all") + const [sort, setSort] = useState("used_desc") + const [page, setPage] = useState(1) + const [editUser, setEditUser] = useState(null) + + // 顶部标签页 + const [tab, setTab] = useState<"users" | "settings" | "orders">("users") + + // 支付记录查询 + const [orderSearch, setOrderSearch] = useState("") + const [orderStatus, setOrderStatus] = useState("all") + const [orderPage, setOrderPage] = useState(1) + + const q = useQuery({ + queryKey: ["admin_storage_users", search, vip, sort, page], + queryFn: () => api>("adminStorageUsers", { + search: search || undefined, + vip: vip === "all" ? undefined : vip, + sort: sort === "used_desc" ? undefined : sort, + page, + page_size: PAGE_SIZE, + }, "GET"), + }) + const users = q.data?.items || [] + const goFirst = (setter: (v: T) => void) => (v: T) => { setter(v); setPage(1) } + + // 支付记录 + const oq = useQuery({ + queryKey: ["admin_storage_orders", orderSearch, orderStatus, orderPage], + queryFn: () => api>("adminStorageOrders", { + search: orderSearch || undefined, + status: orderStatus === "all" ? undefined : orderStatus, + page: orderPage, + page_size: PAGE_SIZE, + }, "GET"), + enabled: tab === "orders", + }) + const orders = oq.data?.items || [] + + // 收费设置 + const [freeMb, setFreeMb] = useState("200") + const [monthPrice, setMonthPrice] = useState("5") + const [monthProductId, setMonthProductId] = useState("") + const [yearPrice, setYearPrice] = useState("29.9") + const [yearProductId, setYearProductId] = useState("") + const [payMode, setPayMode] = useState("short_series_goods") + const [payEnv, setPayEnv] = useState(1) + const [payOfferId, setPayOfferId] = useState("") + const [payAppKey, setPayAppKey] = useState("") + const [paySandboxKey, setPaySandboxKey] = useState("") + const [feeLoading, setFeeLoading] = useState(true) + + useEffect(() => { + api("adminStorageSettings", null, "GET") + .then((d) => { + setFreeMb(String(d.free_mb ?? 200)) + setMonthPrice((d.month_price_cents / 100).toString()) + setMonthProductId(d.month_product_id || "") + setYearPrice((d.year_price_cents / 100).toString()) + setYearProductId(d.year_product_id || "") + setPayMode(d.pay_mode || "short_series_goods") + setPayEnv(d.pay_env ?? 1) + setPayOfferId(d.pay_offer_id || "") + setPayAppKey(d.pay_app_key || "") + setPaySandboxKey(d.pay_sandbox_key || "") + }) + .catch((e) => toast.error((e as Error).message)) + .finally(() => setFeeLoading(false)) + }, []) + + async function saveFee() { + const monthCents = Math.round(parseFloat(monthPrice || "0") * 100) + const yearCents = Math.round(parseFloat(yearPrice || "0") * 100) + const freeMbNum = parseInt(freeMb || "0", 10) + if (freeMbNum <= 0) return toast.error("免费额度需大于0") + if (monthCents <= 0 || yearCents <= 0) return toast.error("套餐价格需大于0") + try { + await api("adminStorageSettings", { + free_mb: freeMbNum, + month_price_cents: monthCents, + month_product_id: monthProductId.trim(), + year_price_cents: yearCents, + year_product_id: yearProductId.trim(), + pay_mode: payMode.trim() || "short_series_goods", + pay_offer_id: payOfferId.trim(), + pay_app_key: payAppKey.trim(), + pay_sandbox_key: paySandboxKey.trim(), + pay_env: payEnv, + }) + toast.success("存储收费配置已保存") + } catch (e) { + toast.error((e as Error).message) + } + } + + return ( +
+ 存储空间管理 + + {/* 顶部标签页 */} +
+ {([ + { k: "users", label: "存储用户管理" }, + { k: "settings", label: "存储收费设置" }, + { k: "orders", label: "支付记录" }, + ] as const).map((t) => ( + + ))} +
+ + {/* ============ 存储用户管理 ============ */} + {tab === "users" && ( + + 存储用户管理 + +
{ e.preventDefault(); goFirst(setSearch)(input.trim()) }}> +
+ +
+ setInput(e.target.value)} placeholder="输入用户ID或昵称…" /> + + +
+
+
+ + +
+
+ + +
+
+ + {q.isPending ? : q.isError ? : !users.length ? : ( +
+ + + + 用户 + 已用空间 + 可用空间 + 套餐 + 开通时间 + 到期时间 + 状态 + 操作 + + + + {users.map((u) => { + const name = u.nickname || u.username || "-" + return ( + + +
+ +
+
{u.nickname || "-"}
+
#{u.user_id || "-"} · {u.username || "-"}
+
+
+
+ {formatBytes(u.used_bytes)} + + {u.storage_vip ? ♾️ 无限 : `${u.free_mb} MB`} + + + {u.storage_vip ? {u.plan_label || "会员"} : 免费用户} + + {fmtTime(u.storage_plan_started_at)} + {u.storage_vip ? fmtTime(u.storage_vip_expire_at) : "-"} + + {u.storage_vip ? 会员 : 免费} + + + + +
+ ) + })} +
+
+ +
+ )} +
+
+ )} + + {/* ============ 存储收费设置 ============ */} + {tab === "settings" && ( + + 存储收费设置 + +
+
+
setFreeMb(e.target.value)} placeholder="200" />
+

用户默认拥有的免费存储空间(MB),超出后引导付费。

+
+ +
+
+ + +
+
setPayOfferId(e.target.value)} placeholder="微信虚拟支付后台的 OfferID" />
+
setPayAppKey(e.target.value)} placeholder="正式环境 AppKey(上线后填写)" />
+
setPaySandboxKey(e.target.value)} placeholder="沙箱环境 AppKey" />
+

留空则回退到服务端 config.yaml 的 virtual_pay 配置,保存后立即生效。

+
+ +
+
+
setMonthPrice(e.target.value)} placeholder="5" />
+
setMonthProductId(e.target.value)} placeholder="在微信虚拟支付后台配置的道具ID" />
+
+ +
+
+
setYearPrice(e.target.value)} placeholder="29.9" />
+
setYearProductId(e.target.value)} placeholder="在微信虚拟支付后台配置的道具ID" />
+
+ +
+ + setPayMode(e.target.value)} placeholder="short_series_goods" /> +

wx.requestVirtualPayment 的 mode 参数,虚拟支付道具直购使用「short_series_goods」。

+
+
+
+ +
+
+
+ )} + + {/* ============ 支付记录 ============ */} + {tab === "orders" && ( + + 存储支付记录 + +
{ e.preventDefault(); setOrderSearch(orderSearch.trim()); setOrderPage(1) }}> +
+ +
+ setOrderSearch(e.target.value)} placeholder="订单号 / 用户ID / 昵称…" /> + + +
+
+
+ + +
+
+ + {oq.isPending ? : oq.isError ? : !orders.length ? : ( +
+ + + + 用户 + 订单号 + 套餐 + 金额 + 环境 + 状态 + 微信订单号 + 创建时间 + 支付时间 + + + + {orders.map((o) => { + const name = o.nickname || o.username || "-" + return ( + + +
+
{name}
+
#{o.display_id || "-"}
+
+
+ {o.out_trade_no} + {o.plan_label || "-"} + {formatAmount(o.amount_cents)} + {o.env_label} + {orderStatusBadge(o.status)} + {o.wx_order_id || "-"} + {fmtTime(o.created_at)} + {o.paid_at ? fmtTime(o.paid_at) : "-"} +
+ ) + })} +
+
+ +
+ )} +
+
+ )} + + setEditUser(null)} /> +
+ ) +} diff --git a/past-go/web/admin/src/pages/Users.tsx b/past-go/web/admin/src/pages/Users.tsx index a0fe3d9..0eac640 100644 --- a/past-go/web/admin/src/pages/Users.tsx +++ b/past-go/web/admin/src/pages/Users.tsx @@ -14,6 +14,15 @@ import { Badge } from "@/components/ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +function formatBytes(n?: number): string { + if (!n || n <= 0) return "0 B" + const units = ["B", "KB", "MB", "GB", "TB"] + let i = 0 + let v = n + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ } + return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}` +} + export default function UsersPage() { const [input, setInput] = useState("") const [search, setSearch] = useState("") @@ -75,7 +84,7 @@ export default function UsersPage() { 用户性别年龄生日 注册时间最后登录登录地址IP - 状态操作 + 存储空间状态操作 @@ -99,6 +108,13 @@ export default function UsersPage() { {fmtTime(u.last_login_at)} {u.last_login_location || u.location || "-"} {u.last_ip || "-"} + + {u.storage_vip ? ( + ♾️ 无限空间 + ) : ( + {formatBytes(u.used_bytes)} + )} + {u.couple_id ? "💕 已绑定" : "💭 未绑定"} e.stopPropagation()}>
diff --git a/weapp/app.json b/weapp/app.json index cc4ef18..52047cd 100644 --- a/weapp/app.json +++ b/weapp/app.json @@ -9,6 +9,7 @@ "pages/album/album", "pages/lovewords/lovewords", "pages/settings/settings", + "pages/storage/storage", "pages/history/history", "pages/profile/profile", "pages/publish/publish", diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js index 2ea8123..34cb9f7 100644 --- a/weapp/pages/album/album.js +++ b/weapp/pages/album/album.js @@ -6,6 +6,7 @@ const { getLayout } = require('../../utils/layout'); // pages/album/album.js - 日记式分组相册 const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request'); const { requestSubscribeAfterAction } = require('../../utils/subscribe'); +const { guardStorageSpace } = require('../../utils/storage-guard'); Page(withDataCache(withListPagination({ onResize: function() { this.setData(getLayout()); }, data: { loading: true, loadError: false, @@ -202,7 +203,9 @@ Page(withDataCache(withListPagination({ chooseImage: function() { var self = this; - wx.chooseImage({ + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ count: 9, sizeType: ['compressed'], sourceType: ['album', 'camera'], @@ -229,6 +232,7 @@ Page(withDataCache(withListPagination({ console.error('Choose image failed:', err); wx.showToast({ title: '选择照片失败', icon: 'none' }); } + }); }); }, onUploadInput: function(e) { @@ -273,19 +277,22 @@ Page(withDataCache(withListPagination({ addMorePhotos: function() { var self = this; - var current = this.data.uploadFilePaths.length; - wx.chooseImage({ - count: 9 - current, - sizeType: ['compressed'], - sourceType: ['album', 'camera'], - success: function(res) { - var newPaths = (res.tempFilePaths || []).map(function(p) { return String(p); }); - var newForms = newPaths.map(function() { return { taken_at: '', location: '', activity: '' }; }); - self.setData({ - uploadFilePaths: self.data.uploadFilePaths.concat(newPaths), - photoFormList: self.data.photoFormList.concat(newForms) - }); - } + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + var current = self.data.uploadFilePaths.length; + wx.chooseImage({ + count: 9 - current, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + var newPaths = (res.tempFilePaths || []).map(function(p) { return String(p); }); + var newForms = newPaths.map(function() { return { taken_at: '', location: '', activity: '' }; }); + self.setData({ + uploadFilePaths: self.data.uploadFilePaths.concat(newPaths), + photoFormList: self.data.photoFormList.concat(newForms) + }); + } + }); }); }, diff --git a/weapp/pages/checkin-detail/checkin-detail.js b/weapp/pages/checkin-detail/checkin-detail.js index e095b56..198316e 100644 --- a/weapp/pages/checkin-detail/checkin-detail.js +++ b/weapp/pages/checkin-detail/checkin-detail.js @@ -4,6 +4,7 @@ const { getLayout } = require('../../utils/layout'); const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request'); const { createSwipeBack } = require('../../utils/util'); const { requestSubscribeAfterAction } = require('../../utils/subscribe'); +const { guardStorageSpace } = require('../../utils/storage-guard'); const COOLDOWN = 5000; Page(withDataCache({ onResize: function() { this.setData(getLayout()); }, @@ -163,9 +164,12 @@ Page(withDataCache({ chooseCheckPhoto: function() { var self = this; - wx.chooseImage({ - count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], - success: function(res) { self.setData({ checkPhoto: res.tempFilePaths[0] }); } + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ + count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], + success: function(res) { self.setData({ checkPhoto: res.tempFilePaths[0] }); } + }); }); }, diff --git a/weapp/pages/diary_edit/diary_edit.js b/weapp/pages/diary_edit/diary_edit.js index 446378f..7716559 100644 --- a/weapp/pages/diary_edit/diary_edit.js +++ b/weapp/pages/diary_edit/diary_edit.js @@ -1,5 +1,6 @@ const { getLayout } = require('../../utils/layout'); const { api, API_BASE, getImageUrl, uploadFileCompat } = require('../../utils/request'); +const { guardStorageSpace } = require('../../utils/storage-guard'); var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌']; var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙']; @@ -71,13 +72,16 @@ Page({ chooseImages: function() { var self = this; - wx.chooseImage({ - count: 9 - this.data.tempImages.length, - sizeType: ['compressed'], - sourceType: ['album', 'camera'], - success: function(res) { - self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) }); - } + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ + count: 9 - self.data.tempImages.length, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) }); + } + }); }); }, diff --git a/weapp/pages/profile/profile.js b/weapp/pages/profile/profile.js index 6e87250..fced185 100644 --- a/weapp/pages/profile/profile.js +++ b/weapp/pages/profile/profile.js @@ -5,12 +5,21 @@ const { getLayout } = require('../../utils/layout'); const { api, getImageUrl } = require('../../utils/request'); const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos'); const { requestSubscribe } = require('../../utils/subscribe'); +const { guardStorageSpace } = require('../../utils/storage-guard'); + +function formatStorageBytes(bytes) { + bytes = bytes || 0; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'; + return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'; +} Page(withDataCache({ onResize: function() { this.setData(getLayout()); }, data: { scrollTop: 0, statsLoading:false, statsError:false, + storageText:'—', storagePercent:0, storageOverQuota:false, storageVip:false, storageExpireText:'', statisticItems:[ {key:'timeline',label:'记忆日',icon:'stat-timeline',count:'—',color:'pink'}, {key:'album',label:'相册',icon:'stat-album',count:'—',color:'yellow'}, @@ -191,6 +200,7 @@ Page(withDataCache({ } this.loadData(); this.loadProfileStats(); + this.loadStorageInfo(); if (!cid && this.data.showInvitePanel && !this._pollTimer) this.startPollCoupleStatus(); // 如果已绑定情侣,关闭邀请面板 if (cid && this.data.showInvitePanel) { @@ -214,7 +224,7 @@ Page(withDataCache({ }, onPullDownRefresh: async function() { - await Promise.all([this.loadData(),this.loadProfileStats()]); + await Promise.all([this.loadData(),this.loadProfileStats(),this.loadStorageInfo()]); wx.stopPullDownRefresh(); }, @@ -486,25 +496,29 @@ Page(withDataCache({ }, chooseAvatar: function() { - wx.chooseImage({ - count: 1, - sizeType: ['compressed'], - sourceType: ['album', 'camera'], - success: async (res) => { - const filePath = res.tempFiles[0].path; - this.setData({ editAvatarUrl: filePath }); - // 上传头像 - try { - const data = await api.uploadAvatar(filePath); - this.setData({ - editAvatarUrl: getImageUrl(data.path), - editAvatarPath: data.path - }); - wx.showToast({ title: '头像上传成功 ✨', icon: 'success' }); - } catch (e) { - wx.showToast({ title: e.error || '上传失败', icon: 'none' }); + const self = this; + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ + count: 1, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: async (res) => { + const filePath = res.tempFiles[0].path; + self.setData({ editAvatarUrl: filePath }); + // 上传头像 + try { + const data = await api.uploadAvatar(filePath); + self.setData({ + editAvatarUrl: getImageUrl(data.path), + editAvatarPath: data.path + }); + wx.showToast({ title: '头像上传成功 ✨', icon: 'success' }); + } catch (e) { + wx.showToast({ title: e.error || '上传失败', icon: 'none' }); + } } - } + }); }); }, @@ -640,16 +654,20 @@ Page(withDataCache({ }, choosePartnerAvatar: function() { if (this.data.partnerSaving || this.data.partnerAvatarUploading || !this._editPartnerId) return; + const self = this; const partnerId = this._editPartnerId; - wx.chooseImage({count:1,sizeType:['compressed'],sourceType:['album','camera'],success:async res=>{ - if (!this.data.showEditPartnerModal || partnerId !== this._editPartnerId) return; - this.setData({partnerAvatarUploading:true}); - try { - const uploaded = await api.uploadPartnerAvatar(res.tempFilePaths[0],partnerId); - this.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploaded.path}); - } catch(error) { wx.showToast({title:error.error || '头像上传失败',icon:'none'}); } - finally { this.setData({partnerAvatarUploading:false}); } - }}); + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({count:1,sizeType:['compressed'],sourceType:['album','camera'],success:async res=>{ + if (!self.data.showEditPartnerModal || partnerId !== self._editPartnerId) return; + self.setData({partnerAvatarUploading:true}); + try { + const uploaded = await api.uploadPartnerAvatar(res.tempFilePaths[0],partnerId); + self.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploaded.path}); + } catch(error) { wx.showToast({title:error.error || '头像上传失败',icon:'none'}); } + finally { self.setData({partnerAvatarUploading:false}); } + }}); + }); }, savePartnerProfile: async function() { const d = this.data; @@ -1014,6 +1032,44 @@ Page(withDataCache({ this.setData({ showShareBindModal: false }); }, + loadStorageInfo: async function() { + try { + const d = await api.storageInfo(); + const freeBytes = d.free_bytes || d.freeBytes || 0; + const usedBytes = d.used_bytes || d.usedBytes || 0; + const isVip = !!(d.is_vip || d.isVip); + const usedText = formatStorageBytes(usedBytes); + if (isVip) { + // 会员:展示「无限空间」+ 已用容量 + 到期时间,不再提示空间已满。 + const expire = d.vip_expire_at || d.vipExpireAt; + this.setData({ + storageVip: true, + storageText: '无限空间', + storagePercent: 0, + storageOverQuota: false, + storageUsedText: usedText, + storageExpireText: expire ? ('有效期至 ' + String(expire).slice(0, 10)) : '已开通无限空间' + }); + } else { + const percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0; + this.setData({ + storageVip: false, + storageText: usedText, + storagePercent: percent, + storageOverQuota: usedBytes > freeBytes, + storageUsedText: usedText, + storageExpireText: '' + }); + } + } catch (e) { + console.error('加载存储信息失败', e); + } + }, + goStorage: function() { + this._savePosBeforeLeave(); + wx.navigateTo({ url: '/pages/storage/storage' }); + }, + loadProfileStats: async function() { this.setData({statsLoading:true,statsError:false}); const keys=['timeline','album','diary','songs','checkin','pet']; @@ -1046,4 +1102,4 @@ Page(withDataCache({ manageRelationship: function() { wx.navigateTo({ url: '/pages/unbind/unbind' }); }, goToSettings: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/settings/settings' }); }, goHome: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/index/index' }); } -}, {"loadData": ["coupleInfo"], "loadProfileStats": ["timelineList","albumList","diaryList","songsList","checkinList","petInfo"]})); +}, {"loadData": ["coupleInfo"], "loadProfileStats": ["timelineList","albumList","diaryList","songsList","checkinList","petInfo"], "loadStorageInfo": ["storageInfo"]})); diff --git a/weapp/pages/profile/profile.wxml b/weapp/pages/profile/profile.wxml index 5a06c35..5cce863 100644 --- a/weapp/pages/profile/profile.wxml +++ b/weapp/pages/profile/profile.wxml @@ -10,6 +10,27 @@ {{partnerName}}ID {{partnerDisplayId}}{{partnerDetail}}{{partnerInitial}} 另一半还没有加入邀请TA,共写这本生活手账绑定情侣 + + + 存储空间 + 👑 无限空间 + + + 已用 {{storageUsedText}}{{storageExpireText ? ' · ' + storageExpireText : ''}} + 查看 › + + + + + 存储空间 + {{storageText}} + + + + {{storageOverQuota ? '已超出免费空间,升级后可继续上传' : '已用 ' + storagePercent + '% 免费空间'}} + {{storageOverQuota ? '升级无限空间 ›' : '查看 ›'}} + + diff --git a/weapp/pages/profile/profile.wxss b/weapp/pages/profile/profile.wxss index 028f022..9042eb0 100644 --- a/weapp/pages/profile/profile.wxss +++ b/weapp/pages/profile/profile.wxss @@ -324,6 +324,24 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll .profile-stat-pressed { opacity:.65; } .profile-stats-retry { text-align:center; color:#B74768; font-size:22rpx; padding:24rpx 0 0; } +/* 存储空间入口卡片。 */ +.j-storage-card { margin-top:32rpx; padding:26rpx 30rpx; background:#FFFDF8; border:1rpx solid #F1DCE4; border-radius:32rpx; } +.j-storage-top { display:flex; justify-content:space-between; align-items:baseline; } +.j-storage-label { font-family:'Kaiti SC','STKaiti','KaiTi',cursive; font-size:28rpx; color:#64424D; } +.j-storage-val { font-size:26rpx; font-weight:600; color:#B74768; } +.j-storage-bar { margin-top:18rpx; height:16rpx; background:#F5E9ED; border-radius:999rpx; overflow:hidden; } +.j-storage-fill { height:100%; background:linear-gradient(90deg,#FF9EB5,#B74768); border-radius:999rpx; transition:width .4s ease; } +.j-storage-bottom { margin-top:14rpx; display:flex; justify-content:space-between; align-items:center; } +.j-storage-hint { font-size:22rpx; color:#A39386; } +.j-storage-cta { font-size:24rpx; color:#B74768; font-weight:500; } + +/* 会员(无限空间)卡片:玫红渐变,突出「无限空间」与到期时间。 */ +.j-storage-vip { background:linear-gradient(135deg,#FF9EB5,#B74768); border:1rpx solid rgba(255,255,255,.4); border-radius:32rpx; } +.j-storage-vip .j-storage-label { color:#FFF; } +.j-storage-vip .j-storage-val.j-vip { color:#FFF; font-size:28rpx; } +.j-storage-vip .j-storage-hint { color:rgba(255,255,255,.9); } +.j-storage-vip .j-storage-cta { color:#FFF; } + /* 本人与对方资料卡共用完整样式,整卡点击编辑。 */ .j-body > .j-profile-summary, .j-body > .j-relation-card { diff --git a/weapp/pages/publish/publish.js b/weapp/pages/publish/publish.js index 7012efd..fd3e95d 100644 --- a/weapp/pages/publish/publish.js +++ b/weapp/pages/publish/publish.js @@ -3,6 +3,7 @@ const { getLayout } = require('../../utils/layout'); const { api, API_BASE, uploadFileCompat } = require('../../utils/request'); const { createSwipeBack } = require('../../utils/util'); const { requestSubscribeAfterAction } = require('../../utils/subscribe'); +const { guardStorageSpace } = require('../../utils/storage-guard'); var MOOD_OPTIONS = ['😊', '🥰', '😢', '😤', '😴', '🤩', '🥺', '😎', '🤔', '😭', '😋', '😌']; var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️', '🌤️', '🌥️', '🌈', '🌪️', '🌫️', '🌙']; @@ -293,28 +294,34 @@ Page({ // ===== 封面图片选择(相册)===== chooseCover: function() { var self = this; - wx.chooseImage({ - count: 1, - sizeType: ['compressed'], - sourceType: ['album', 'camera'], - success: function(res) { - self.setData({ coverPreview: res.tempFilePaths[0], coverPath: res.tempFilePaths[0] }); - self._saveDraft(); - } + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ + count: 1, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + self.setData({ coverPreview: res.tempFilePaths[0], coverPath: res.tempFilePaths[0] }); + self._saveDraft(); + } + }); }); }, // ===== 图片选择(日记)===== chooseImages: function() { var self = this; - wx.chooseImage({ - count: 9 - this.data.tempImages.length, - sizeType: ['compressed'], - sourceType: ['album', 'camera'], - success: function(res) { - self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) }); - self._saveDraft(); - } + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ + count: 9 - self.data.tempImages.length, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + self.setData({ tempImages: self.data.tempImages.concat(res.tempFilePaths) }); + self._saveDraft(); + } + }); }); }, @@ -534,14 +541,17 @@ Page({ // ===== 背景图 ===== chooseBgImage: function() { var self = this; - wx.chooseImage({ - count: 1, - sizeType: ['compressed'], - sourceType: ['album', 'camera'], - success: function(res) { - var filePath = res.tempFilePaths[0]; - self.setData({ bgImageUrl: filePath, bgImagePath: '' }); - } + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ + count: 1, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: function(res) { + var filePath = res.tempFilePaths[0]; + self.setData({ bgImageUrl: filePath, bgImagePath: '' }); + } + }); }); }, diff --git a/weapp/pages/settings/settings.js b/weapp/pages/settings/settings.js index c6227c7..2f58b47 100644 --- a/weapp/pages/settings/settings.js +++ b/weapp/pages/settings/settings.js @@ -2,6 +2,7 @@ const { getLayout } = require('../../utils/layout'); // pages/settings/settings.js const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request'); const { requestSubscribe, getSubscribeStatus } = require('../../utils/subscribe'); +const { guardStorageSpace } = require('../../utils/storage-guard'); Page({ onResize: function() { this.setData(getLayout()); }, data: { @@ -196,10 +197,13 @@ Page({ }, chooseBgImage: function() { var self = this; - wx.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], - success: function(res) { - self.setData({ bgPreview: res.tempFilePaths[0], bgFilePath: res.tempFilePaths[0] }); - } + guardStorageSpace(0).then(function(ok) { + if (!ok) return; + wx.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], + success: function(res) { + self.setData({ bgPreview: res.tempFilePaths[0], bgFilePath: res.tempFilePaths[0] }); + } + }); }); }, saveBgImage: async function() { diff --git a/weapp/pages/storage/storage.js b/weapp/pages/storage/storage.js new file mode 100644 index 0000000..16cf167 --- /dev/null +++ b/weapp/pages/storage/storage.js @@ -0,0 +1,134 @@ +const { getLayout } = require('../../utils/layout'); +const { api } = require('../../utils/request'); + +function formatBytes(bytes) { + bytes = bytes || 0; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'; + return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'; +} + +Page({ + onResize: function () { this.setData(getLayout()); }, + + data: { + loading: true, + error: false, + usedBytes: 0, + usedText: '0 MB', + freeMb: 200, + freeText: '200 MB', + percent: 0, + overQuota: false, + isVip: false, + vipExpireText: '', + plans: [], + paying: false, + }, + + onLoad: function () { this.setData(getLayout()); }, + onShow: function () { this.loadInfo(); }, + onPullDownRefresh: function () { this.loadInfo().then(function () { wx.stopPullDownRefresh(); }); }, + + loadInfo: function () { + var self = this; + this.setData({ loading: true, error: false }); + return api.storageInfo().then(function (d) { + var freeBytes = d.free_bytes || d.freeBytes || 0; + var usedBytes = d.used_bytes || d.usedBytes || 0; + var percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0; + var overQuota = usedBytes > freeBytes; + var plans = (d.plans || []).map(function (p) { + var cents = p.price_cents || p.priceCents || 0; + return { + key: p.key, + name: p.name, + priceText: (cents / 100).toFixed(cents % 100 === 0 ? 0 : 1), + unit: p.key === 'year' ? '年' : '月', + productId: p.product_id || p.productId, + }; + }); + var vipExpireText = ''; + if (d.is_vip || d.isVip) { + var expire = d.vip_expire_at || d.vipExpireAt ? String(d.vip_expire_at || d.vipExpireAt).slice(0, 10) : ''; + vipExpireText = expire ? ('有效期至 ' + expire) : '已开通无限空间'; + } + self.setData({ + loading: false, + usedBytes: usedBytes, + usedText: formatBytes(usedBytes), + freeMb: d.free_mb || d.freeMb || 200, + freeText: formatBytes(freeBytes), + percent: percent, + overQuota: overQuota, + isVip: !!(d.is_vip || d.isVip), + vipExpireText: vipExpireText, + plans: plans, + }); + }).catch(function (e) { + self.setData({ loading: false, error: true }); + console.error('加载存储信息失败', e); + }); + }, + + buyPlan: function (e) { + var key = e.currentTarget.dataset.key; + var self = this; + if (this.data.paying) return; + var plan = null; + for (var i = 0; i < this.data.plans.length; i++) { + if (this.data.plans[i].key === key) { plan = this.data.plans[i]; break; } + } + if (!plan) return; + + wx.showLoading({ title: '下单中...', mask: true }); + this.setData({ paying: true }); + api.storageCreateOrder({ plan_key: key }).then(function (order) { + wx.hideLoading(); + if (!order || !(order.sign_data || order.signData)) { + self.setData({ paying: false }); + wx.showToast({ title: '下单失败', icon: 'none' }); + return; + } + self.callVirtualPay(order); + }).catch(function (err) { + wx.hideLoading(); + self.setData({ paying: false }); + wx.showToast({ title: (err && err.error) || '下单失败', icon: 'none' }); + }); + }, + + callVirtualPay: function (order) { + var self = this; + if (!wx.requestVirtualPayment) { + this.setData({ paying: false }); + wx.showModal({ + title: '版本过低', + content: '请升级微信至最新版本后再试。', + showCancel: false + }); + return; + } + // wx.requestVirtualPayment 仅接受 signData / paySig / signature / mode 四个参数; + // offerId、env、productId、goodsPrice 等均已编码在 signData JSON 字符串内,不可再平铺为顶层参数。 + wx.requestVirtualPayment({ + signData: order.sign_data || order.signData, + paySig: order.pay_sig || order.paySig, + signature: order.signature, + mode: order.mode || 'short_series_goods', + success: function () { + self.setData({ paying: false }); + wx.showToast({ title: '支付成功', icon: 'success' }); + setTimeout(function () { self.loadInfo(); }, 1200); + }, + fail: function (err) { + self.setData({ paying: false }); + var msg = '支付未完成'; + var m = (err && err.errMsg) || ''; + if (m.indexOf('cancel') >= 0) msg = '已取消支付'; + else if (m.indexOf('fail') >= 0) msg = '支付失败,请稍后重试'; + wx.showToast({ title: msg, icon: 'none' }); + } + }); + }, +}); diff --git a/weapp/pages/storage/storage.json b/weapp/pages/storage/storage.json new file mode 100644 index 0000000..34ce546 --- /dev/null +++ b/weapp/pages/storage/storage.json @@ -0,0 +1,7 @@ +{ + "usingComponents": { + "app-nav": "/components/app-nav/app-nav", + "page-state": "/components/page-state/page-state" + }, + "enablePullDownRefresh": true +} diff --git a/weapp/pages/storage/storage.wxml b/weapp/pages/storage/storage.wxml new file mode 100644 index 0000000..20a1924 --- /dev/null +++ b/weapp/pages/storage/storage.wxml @@ -0,0 +1,52 @@ + + + + + + + + + + + 已用空间 + 👑 无限空间 + + + {{usedText}} + / {{freeText}} 免费 + + + + + {{vipExpireText}} + 已超出免费空间,升级后即可继续上传 + 已使用 {{percent}}% 免费空间 + + + + 升级无限空间 + + + + {{item.name}} + 无限存储空间,照片视频想存就存 + + + ¥{{item.priceText}} + /{{item.unit}} + + + + + + + 📌 说明 + • 默认提供 {{freeMb}}MB 免费空间 + • 开通会员后,照片、视频等不再限制存储空间 + • 会员到期后,已上传内容仍可正常查看 + + + + + + diff --git a/weapp/pages/storage/storage.wxss b/weapp/pages/storage/storage.wxss new file mode 100644 index 0000000..9fe4abf --- /dev/null +++ b/weapp/pages/storage/storage.wxss @@ -0,0 +1,157 @@ +/* pages/storage/storage.wxss */ +@import '../../styles/common.wxss'; +page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; } +scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; } + +/* 已用空间卡片 */ +.storage-card { + background: #FFFFFF; + border-radius: 28rpx; + padding: 36rpx 32rpx; + margin: 16rpx 0 8rpx; + box-shadow: 0 8rpx 32rpx rgba(183, 71, 104, 0.08); + border: 2rpx solid rgba(255, 107, 157, 0.10); +} +.storage-card-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.storage-title { + font-size: 28rpx; + font-weight: 600; + color: #666; +} +.storage-vip { + font-size: 24rpx; + font-weight: 600; + color: #B74768; + background: #FFE4EC; + padding: 6rpx 18rpx; + border-radius: 999rpx; +} +.storage-used { + margin-top: 16rpx; + display: flex; + align-items: baseline; +} +.storage-num { + font-size: 56rpx; + font-weight: 700; + color: #B74768; + line-height: 1; +} +.storage-of { + font-size: 26rpx; + color: #999; + margin-left: 10rpx; +} +.storage-bar { + margin-top: 24rpx; + height: 16rpx; + background: #F5E9ED; + border-radius: 999rpx; + overflow: hidden; +} +.storage-bar-fill { + height: 100%; + background: linear-gradient(90deg, #FF9EB5, #B74768); + border-radius: 999rpx; + transition: width 0.4s ease; +} +.storage-hint { + margin-top: 16rpx; + font-size: 24rpx; + color: #999; +} +.storage-hint.over { + color: #FF4757; + font-weight: 500; +} + +/* 套餐卡片 */ +.plan-list { + margin-top: 8rpx; +} +.plan-card { + display: flex; + align-items: center; + justify-content: space-between; + background: #FFFFFF; + border-radius: 24rpx; + padding: 30rpx 28rpx; + margin-bottom: 20rpx; + box-shadow: 0 6rpx 24rpx rgba(183, 71, 104, 0.06); + border: 2rpx solid rgba(255, 107, 157, 0.10); + transition: all 0.3s ease; +} +.plan-card:active { + transform: scale(0.98); + border-color: rgba(183, 71, 104, 0.35); + background: #FFF8FA; +} +.plan-info { + flex: 1; + min-width: 0; + margin-right: 16rpx; +} +.plan-name { + font-size: 30rpx; + font-weight: 600; + color: #333; + display: block; +} +.plan-desc { + font-size: 22rpx; + color: #999; + margin-top: 8rpx; + display: block; +} +.plan-price { + display: flex; + align-items: baseline; + flex-shrink: 0; +} +.plan-amount { + font-size: 40rpx; + font-weight: 700; + color: #B74768; +} +.plan-unit { + font-size: 22rpx; + color: #999; + margin-left: 4rpx; +} + +/* 说明 */ +.storage-note { + background: #FFF9F4; + border-radius: 20rpx; + padding: 24rpx 28rpx; + margin-top: 12rpx; + border: 2rpx solid rgba(255, 107, 157, 0.12); +} +.note-title { + font-size: 26rpx; + font-weight: 700; + color: #B74768; + display: block; + margin-bottom: 14rpx; +} +.note-item { + font-size: 24rpx; + color: #666; + display: block; + line-height: 1.9; +} + +.section-heading { + font-family: 'Kaiti SC', 'STKaiti', 'KaiTi', cursive; + font-weight: 400; + letter-spacing: 1rpx; +} + +@import '../../styles/design.wxss'; +@import '../../styles/journal.wxss'; +@import '../../styles/button-theme.wxss'; +@import '../../styles/typography.wxss'; diff --git a/weapp/utils/request.js b/weapp/utils/request.js index 784f4c1..38a6962 100644 --- a/weapp/utils/request.js +++ b/weapp/utils/request.js @@ -63,6 +63,25 @@ function formatUploadError(data) { return msg; } +// 存储配额错误码(与后端一致),上传超限时引导用户付费。 +var STORAGE_QUOTA_EXCEEDED = 10001; +function isStorageQuotaError(data) { + return !!(data && data.code === STORAGE_QUOTA_EXCEEDED); +} +function maybeRedirectStorage(data) { + if (!isStorageQuotaError(data)) return false; + wx.showModal({ + title: '存储空间不足', + content: '免费空间已用完,升级为无限空间会员后即可继续上传。', + confirmText: '去升级', + cancelText: '暂不', + success: function (res) { + if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' }); + } + }); + return true; +} + /** * 通用请求方法 * @param {string} action - API action 名称 @@ -219,6 +238,7 @@ const api = { ); wx.hideLoading(); if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; } + maybeRedirectStorage(data); wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { @@ -239,6 +259,7 @@ const api = { ); wx.hideLoading(); if (data && !data.error && (data.path || data.success)) { dataCache.mutation('uploadAvatar', data); return data; } + maybeRedirectStorage(data); wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { @@ -336,6 +357,7 @@ const api = { }); wx.hideLoading(); if (data && !data.error && (data.success || data.path || data.uploaded)) { dataCache.mutation('photoUpload', data); return data; } + maybeRedirectStorage(data); wx.showToast({ title: formatUploadError(data), icon: 'none' }); throw data || { error: '上传失败' }; } catch(e) { @@ -376,6 +398,10 @@ const api = { getBgConfig: () => request('getBgConfig', null, 'GET'), deleteBgImage: () => request('deleteBgImage', {}), + // 存储空间 + storageInfo: () => request('storageInfo', null, 'GET'), + storageCreateOrder: (data) => request('storageCreateOrder', data), + // 操作记录 activityLogList: (data) => request('activityLogList', data, 'GET'), diff --git a/weapp/utils/storage-guard.js b/weapp/utils/storage-guard.js new file mode 100644 index 0000000..e5947b8 --- /dev/null +++ b/weapp/utils/storage-guard.js @@ -0,0 +1,43 @@ +// utils/storage-guard.js +// 存储空间统一拦截:上传照片/图片前检查剩余空间,超出时在按钮处直接阻止上传。 +const { api } = require('./request'); + +// 判断是否超出配额(会员无限空间,不拦截) +function quotaExceeded(d, extraBytes) { + if (!d) return false; + const isVip = d.is_vip || d.isVip; + if (isVip) return false; + const free = d.free_bytes || d.freeBytes || 0; + const used = d.used_bytes || d.usedBytes || 0; + return used + (extraBytes || 0) > free; +} + +// 空间不足时的统一提示 +function showQuotaBlocked() { + wx.showModal({ + title: '存储空间不足', + content: '当前存储空间已满,请升级为无限空间会员后再上传照片。', + confirmText: '去升级', + cancelText: '取消', + success: function (res) { + if (res.confirm) wx.navigateTo({ url: '/pages/storage/storage' }); + } + }); +} + +// 上传前检查:空间充足 resolve(true);不足则弹窗提示并 resolve(false);查询失败不阻断上传(由服务端二次校验兜底)。 +function guardStorageSpace(extraBytes) { + return api.storageInfo() + .then(function (d) { + if (quotaExceeded(d, extraBytes || 0)) { + showQuotaBlocked(); + return false; + } + return true; + }) + .catch(function () { + return true; + }); +} + +module.exports = { guardStorageSpace, quotaExceeded, showQuotaBlocked }; diff --git a/部署验收清单.md b/部署验收清单.md new file mode 100644 index 0000000..827d4a3 --- /dev/null +++ b/部署验收清单.md @@ -0,0 +1,87 @@ +# 虚拟支付接入修复 + 部署验收清单 + +## 一、本次修复的两个问题 + +### 问题 1:后台「存储空间管理」提示「请先登录」 +- **根因**:新增的 `admin_storage_users`、`admin_storage_user_update` 两个接口未加入后台鉴权白名单,被当成普通用户接口校验,所以返回「请先登录」。 +- **修复**:已加入 `adminActions` 白名单,现在走后台 Cookie 会话鉴权,可正常拉取用户列表。 + +### 问题 2:前端存储支付页显示「支付失败,请稍后再试」 +- **根因 A(后端)**:订单号 `out_trade_no` 为 `"SP" + 32 位随机串 = 34 位`,**超过微信要求的 8–32 位**,被微信拒绝。 + - 修复:改为 `"S" + 31 位随机串 = 32 位`。 +- **根因 B(前端)**:`wx.requestVirtualPayment` 误把 `offerId / env / productId / goodsPrice / buyQuantity / currencyType` 作为**顶层参数**传入。这些字段只能存在于 `signData` JSON 字符串内部,顶层仅允许 `signData / paySig / signature / mode` 四个参数。 + - 修复:`pages/storage/storage.js` 已改为只传这四个参数。 + +### 额外增强:兜底查单(query_order) +- 新增服务端主动查单逻辑:服务启动后定时(默认 5 分钟)扫描 24 小时内未发货订单,调用微信 `query_order` 接口,已支付但推送丢失的订单会**自动补发货开通会员**,已关闭订单自动标记 `closed`。 +- 新增 `internal/virtualpay` 的 `QueryOrder` 方法(签名:`pay_sig = HMAC-SHA256(AppKey, "/xpay/query_order&" + body)`)。 + +--- + +## 二、部署步骤 + +1. **后端**:用 `dist/past-windows-deploy.zip` 替换服务器上的部署目录并重启。 +2. **小程序前端**:`weapp/pages/storage/storage.js` 已改动,请在微信开发者工具重新上传并提交审核发布。 +3. **配置**:按下面清单逐项配置虚拟支付。 + +--- + +## 三、部署前检查清单(逐项验收) + +> 对照微信个人主体虚拟支付接入文档,逐项验收。 + +| # | 检查项 | 本项目验收方法 / 操作 | +|---|--------|------------------------| +| 1 | 开放条件:个人主体 + 大陆身份证 + 服务类目「工具」+ 已完成认证与备案 | 微信公众平台(MP)后台确认 | +| 2 | 全终端月支付限额 10 万元,已了解并告知用户 | 确认知晓,可在会员页注明 | +| 3 | MP 后台「虚拟支付」能力已开通 | MP 后台 → 功能 → 虚拟支付,确认状态为已开通 | +| 4 | 已拿到 AppID / OfferID / 现网 AppKey | 见下方「配置项 A」 | +| 5 | 需 iOS 支付时已配置小程序简称 | 仅 iOS 需要,Android 可跳过 | +| 6 | 消息推送接收已部署 | 服务已提供回调地址(见「配置项 B」) | +| 7 | MP 后台已配置发货推送 URL,并完成一笔测试支付 | 见下方「配置项 B」+「沙箱测试」 | +| 8 | 服务器验签逻辑通过 | 已实现 `sha1(sort(token,timestamp,nonce))` 明文验签,`token` 需与 MP 后台一致 | +| 9 | 发货推送已做幂等 | 已实现:按 `out_trade_no` + `status='success'` 去重,重复推送不会重复开通 | +| 10 | 前端 `wx.requestVirtualPayment` 打通 | 本次已修复参数,见「问题 2」 | +| 11 | 兜底查单逻辑就绪(query_order) | 本次已新增,服务启动后自动运行 | +| 12 | 退款规则 / 结算周期与费率已说明 | 虚拟支付结算周期 T+1,费率见 MP 后台说明,需在会员页告知 | +| 13 | 上线后小额真单验证 | 正式上线后做一笔真实小额支付,确认到账与发货 | + +--- + +## 配置项 A:支付通道配置(后台「存储空间」→「存储收费设置」) + +| 字段 | 说明 | 沙箱(测试) | 正式(上线) | +|------|------|-------------|-------------| +| `pay_env` | 支付环境 | `1` | **`0`** | +| `pay_offer_id` | 支付应用 ID | 沙箱 OfferID | 正式 OfferID | +| `pay_app_key` | 正式环境 AppKey | 留空 | **正式 AppKey** | +| `pay_sandbox_key` | 沙箱 AppKey | 沙箱 AppKey | 可留空 | +| `month_product_id` | 包月道具 ID | 沙箱道具 ID | 正式道具 ID | +| `year_product_id` | 包年道具 ID | 沙箱道具 ID | 正式道具 ID | +| `month_price_cents` | 包月价格(分) | 与道具价格一致 | 与道具价格一致 | +| `year_price_cents` | 包年价格(分) | 与道具价格一致 | 与道具价格一致 | +| `pay_mode` | 支付模式 | `short_series_goods` | `short_series_goods` | + +> **重要**:`month_product_id / year_product_id` 必须在微信「虚拟支付 → 道具管理」里创建,且**道具价格(分)必须与 `month_price_cents / year_price_cents` 完全一致**,否则支付时价格校验失败。 + +> `config.yaml` 里 `virtual_pay.env` 默认是 `1`(沙箱)。**正式上线前务必改成 `0`**,或在后台把 `pay_env` 改为 `0`(后台优先)。 + +--- + +## 配置项 B:发货推送回调(微信后台「消息推送」) + +- **URL**:`https://你的域名/api/storage_pay_notify` +- **Token**:与 `config.yaml` 的 `virtual_pay.notify_token` 保持一致 +- **消息加密方式**:选择 **明文模式**(当前代码按明文模式验签;若选安全模式需另行实现 AES 解密) + +配置 URL 时微信会发起 GET 验证,服务端已实现 `echostr` 回显 + 签名校验,验证通过即可。 + +--- + +## 沙箱测试流程(上线前必做) + +1. 后台将 `pay_env` 设为 `1`,填入沙箱 OfferID / 沙箱 AppKey / 沙箱道具 ID。 +2. 用测试微信号打开小程序,进入「我的 → 存储空间」发起支付。 +3. 确认能拉起 `wx.requestVirtualPayment`,完成沙箱支付。 +4. 观察:支付成功后会员自动到账;若推送延迟,5 分钟内兜底查单会自动补发货。 +5. 测试通过后,把 `pay_env` 改为 `0`、换成正式 OfferID / AppKey / 道具 ID,再提交审核发布。