feat: 存储空间管理、虚拟支付接入与容量守卫

- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询
- 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order)
- 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数
- 前端上传前容量预检,超容引导升级无限空间
- 我的页会员卡片展示「无限空间」与已用容量/到期时间
- 后台「存储空间管理」新增支付记录标签页
This commit is contained in:
CodeBuddy
2026-10-26 23:07:09 +08:00
parent 0f5dc01674
commit 3ce6edc9a1
41 changed files with 2387 additions and 108 deletions
+8
View File
@@ -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,
+9
View File
@@ -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(可选)
+67
View File
@@ -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` 时区。
@@ -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
+12
View File
@@ -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 {
+10 -1
View File
@@ -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 {
+15
View File
@@ -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()
+1
View File
@@ -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 周,周日归上一个周一
+8
View File
@@ -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 {
+22
View File
@@ -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) })
+28 -4
View File
@@ -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;未绑定记录必须按用户和日期查找。
+23 -1
View File
@@ -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
@@ -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)}}},
+669
View File
@@ -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 := "<xml><ErrCode>" + store.ToStr(errCode) + "</ErrCode><ErrMsg><![CDATA[" + errMsg + "]]></ErrMsg></xml>"
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))
}
+8 -4
View File
@@ -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
}
+13
View File
@@ -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
+2 -2
View File
@@ -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))
+24
View File
@@ -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隐藏'"},
}
+138
View File
@@ -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)
}
@@ -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 {
+11 -10
View File
@@ -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
+2
View File
@@ -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() {
<Route path="unbind" element={<UnbindPage />} />
<Route path="pets" element={<PetsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="storage" element={<StoragePage />} />
<Route path="subscribe-messages" element={<SubscribeMessagesPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
+2 -1
View File
@@ -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: "系统" },
]
+530
View File
@@ -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 (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>💾 修改存储空间</DialogTitle>
<DialogDescription>为「{name}」调整存储容量与会员套餐</DialogDescription>
</DialogHeader>
{user && (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-xl bg-gradient-to-r from-pink-50 to-violet-50 p-3 dark:from-pink-950/30 dark:to-violet-950/30">
<Avatar path={user.avatar} name={name} size={44} />
<div className="min-w-0 text-sm">
<div className="font-semibold">{name}<span className="ml-2 text-xs font-normal text-muted-foreground">#{user.user_id || "-"}</span></div>
<div className="text-xs text-muted-foreground">已用 {formatBytes(user.used_bytes)}</div>
</div>
</div>
<div className="space-y-1">
<Label>存储容量(MB)</Label>
<Input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} placeholder="留空或 0 = 使用默认免费额度" />
<p className="text-xs text-muted-foreground">仅对非会员生效,设置后覆盖全局默认免费额度。</p>
</div>
<div className="space-y-1">
<Label>会员套餐</Label>
<Select value={plan} onValueChange={setPlan}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">免费用户(无会员)</SelectItem>
<SelectItem value="month">包月无限空间</SelectItem>
<SelectItem value="year">包年无限空间</SelectItem>
</SelectContent>
</Select>
</div>
{plan !== "free" && (
<div className="space-y-1">
<Label>到期时间(留空自动计算)</Label>
<Input type="datetime-local" value={expire} onChange={(e) => setExpire(e.target.value)} />
<p className="text-xs text-muted-foreground">包月自动加 1 个月、包年自动加 12 个月;也可手动指定到期时间。</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={save}>保存</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
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 <Badge variant="default">已支付</Badge>
case "pending": return <Badge variant="secondary">待支付</Badge>
case "closed": return <Badge variant="outline">已关闭</Badge>
default: return <Badge variant="outline">{status}</Badge>
}
}
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<StorageUser | null>(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<Paged<StorageUser>>("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 = <T,>(setter: (v: T) => void) => (v: T) => { setter(v); setPage(1) }
// 支付记录
const oq = useQuery({
queryKey: ["admin_storage_orders", orderSearch, orderStatus, orderPage],
queryFn: () => api<Paged<StorageOrder>>("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<StorageSettingsResp>("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 (
<div>
<PageTitle count={tab === "users" && q.data ? `共 ${q.data.total} 人` : tab === "orders" && oq.data ? `共 ${oq.data.total} 笔` : undefined}>存储空间管理</PageTitle>
{/* 顶部标签页 */}
<div className="mb-5 flex flex-wrap gap-2">
{([
{ k: "users", label: "存储用户管理" },
{ k: "settings", label: "存储收费设置" },
{ k: "orders", label: "支付记录" },
] as const).map((t) => (
<Button key={t.k} variant={tab === t.k ? "default" : "outline"} onClick={() => setTab(t.k)}>
{t.label}
</Button>
))}
</div>
{/* ============ 存储用户管理 ============ */}
{tab === "users" && (
<Card>
<CardHeader><CardTitle className="text-base">存储用户管理</CardTitle></CardHeader>
<CardContent className="space-y-4">
<form className="flex flex-wrap items-end gap-3" onSubmit={(e) => { e.preventDefault(); goFirst(setSearch)(input.trim()) }}>
<div className="min-w-56 flex-1 space-y-1">
<Label>🔍 搜索用户</Label>
<div className="flex gap-2">
<Input value={input} onChange={(e) => setInput(e.target.value)} placeholder="输入用户ID或昵称…" />
<Button type="submit"><Search className="size-4" />搜索</Button>
<Button type="button" variant="outline" onClick={() => { setInput(""); goFirst(setSearch)("") }}><X className="size-4" />清除</Button>
</div>
</div>
<div className="space-y-1">
<Label>💎 会员状态</Label>
<Select value={vip} onValueChange={goFirst(setVip)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">全部用户</SelectItem>
<SelectItem value="vip">仅会员</SelectItem>
<SelectItem value="free">仅免费</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>📊 排序</Label>
<Select value={sort} onValueChange={goFirst(setSort)}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="used_desc">已用空间从大到小</SelectItem>
<SelectItem value="used_asc">已用空间从小到大</SelectItem>
<SelectItem value="expire">按到期时间</SelectItem>
<SelectItem value="newest">最新注册</SelectItem>
<SelectItem value="last_login">最后登录</SelectItem>
</SelectContent>
</Select>
</div>
</form>
{q.isPending ? <Loading rows={6} /> : q.isError ? <ErrorBox error={q.error} /> : !users.length ? <Empty icon="💾" text="暂无用户" /> : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>用户</TableHead>
<TableHead>已用空间</TableHead>
<TableHead>可用空间</TableHead>
<TableHead>套餐</TableHead>
<TableHead>开通时间</TableHead>
<TableHead>到期时间</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => {
const name = u.nickname || u.username || "-"
return (
<TableRow key={u.id}>
<TableCell>
<div className="flex items-center gap-2.5">
<Avatar path={u.avatar} name={name} />
<div>
<div className="font-medium">{u.nickname || "-"}</div>
<div className="text-xs text-muted-foreground">#{u.user_id || "-"} · {u.username || "-"}</div>
</div>
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-sm">{formatBytes(u.used_bytes)}</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{u.storage_vip ? <span className="text-muted-foreground">♾️ 无限</span> : `${u.free_mb} MB`}
</TableCell>
<TableCell>
{u.storage_vip ? <Badge variant="default">{u.plan_label || "会员"}</Badge> : <Badge variant="outline">免费用户</Badge>}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(u.storage_plan_started_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{u.storage_vip ? fmtTime(u.storage_vip_expire_at) : "-"}</TableCell>
<TableCell>
{u.storage_vip ? <Badge variant="default"><Crown className="mr-1 size-3" />会员</Badge> : <span className="text-xs text-muted-foreground">免费</span>}
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => setEditUser(u)}>✏️ 修改</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<Pagination page={q.data.page} totalPages={q.data.total_pages} total={q.data.total} onChange={setPage} />
</div>
)}
</CardContent>
</Card>
)}
{/* ============ 存储收费设置 ============ */}
{tab === "settings" && (
<Card className="mt-5">
<CardHeader><CardTitle className="text-base">存储收费设置</CardTitle></CardHeader>
<CardContent>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-3">
<div className="space-y-1"><Label>免费额度</Label><Input type="number" value={freeMb} onChange={(e) => setFreeMb(e.target.value)} placeholder="200" /></div>
<p className="text-sm text-muted-foreground">用户默认拥有的免费存储空间(MB),超出后引导付费。</p>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label>支付环境</Label>
<Select value={String(payEnv)} onValueChange={(v) => setPayEnv(Number(v))}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">沙箱环境(测试)</SelectItem>
<SelectItem value="0">正式环境</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label>支付应用 ID(OfferID)</Label><Input value={payOfferId} onChange={(e) => setPayOfferId(e.target.value)} placeholder="微信虚拟支付后台的 OfferID" /></div>
<div className="space-y-1"><Label>正式环境 AppKey</Label><Input type="password" value={payAppKey} onChange={(e) => setPayAppKey(e.target.value)} placeholder="正式环境 AppKey(上线后填写)" /></div>
<div className="space-y-1"><Label>沙箱环境 AppKey</Label><Input type="password" value={paySandboxKey} onChange={(e) => setPaySandboxKey(e.target.value)} placeholder="沙箱环境 AppKey" /></div>
<p className="text-sm text-muted-foreground">留空则回退到服务端 config.yaml 的 virtual_pay 配置,保存后立即生效。</p>
</div>
<div className="space-y-3">
<div className="space-y-1"><Label>包月套餐</Label></div>
<div className="space-y-1"><Label>价格(元/月)</Label><Input type="number" step="0.01" value={monthPrice} onChange={(e) => setMonthPrice(e.target.value)} placeholder="5" /></div>
<div className="space-y-1"><Label>虚拟支付道具ID</Label><Input value={monthProductId} onChange={(e) => setMonthProductId(e.target.value)} placeholder="在微信虚拟支付后台配置的道具ID" /></div>
</div>
<div className="space-y-3">
<div className="space-y-1"><Label>包年套餐</Label></div>
<div className="space-y-1"><Label>价格(元/年)</Label><Input type="number" step="0.01" value={yearPrice} onChange={(e) => setYearPrice(e.target.value)} placeholder="29.9" /></div>
<div className="space-y-1"><Label>虚拟支付道具ID</Label><Input value={yearProductId} onChange={(e) => setYearProductId(e.target.value)} placeholder="在微信虚拟支付后台配置的道具ID" /></div>
</div>
<div className="space-y-1 md:col-span-2">
<Label>支付模式(mode)</Label>
<Input value={payMode} onChange={(e) => setPayMode(e.target.value)} placeholder="short_series_goods" />
<p className="text-sm text-muted-foreground">wx.requestVirtualPayment 的 mode 参数,虚拟支付道具直购使用「short_series_goods」。</p>
</div>
</div>
<div className="mt-5">
<Button onClick={saveFee} disabled={feeLoading}>保存收费设置</Button>
</div>
</CardContent>
</Card>
)}
{/* ============ 支付记录 ============ */}
{tab === "orders" && (
<Card className="mt-5">
<CardHeader><CardTitle className="text-base">存储支付记录</CardTitle></CardHeader>
<CardContent className="space-y-4">
<form className="flex flex-wrap items-end gap-3" onSubmit={(e) => { e.preventDefault(); setOrderSearch(orderSearch.trim()); setOrderPage(1) }}>
<div className="min-w-56 flex-1 space-y-1">
<Label>🔍 搜索</Label>
<div className="flex gap-2">
<Input value={orderSearch} onChange={(e) => setOrderSearch(e.target.value)} placeholder="订单号 / 用户ID / 昵称…" />
<Button type="submit"><Search className="size-4" />搜索</Button>
<Button type="button" variant="outline" onClick={() => { setOrderSearch(""); setOrderStatus("all"); setOrderPage(1) }}><X className="size-4" />清除</Button>
</div>
</div>
<div className="space-y-1">
<Label>📌 状态</Label>
<Select value={orderStatus} onValueChange={(v) => { setOrderStatus(v); setOrderPage(1) }}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">已支付</SelectItem>
<SelectItem value="pending">待支付</SelectItem>
<SelectItem value="closed">已关闭</SelectItem>
</SelectContent>
</Select>
</div>
</form>
{oq.isPending ? <Loading rows={6} /> : oq.isError ? <ErrorBox error={oq.error} /> : !orders.length ? <Empty icon="💰" text="暂无支付记录" /> : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>用户</TableHead>
<TableHead>订单号</TableHead>
<TableHead>套餐</TableHead>
<TableHead>金额</TableHead>
<TableHead>环境</TableHead>
<TableHead>状态</TableHead>
<TableHead>微信订单号</TableHead>
<TableHead>创建时间</TableHead>
<TableHead>支付时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((o) => {
const name = o.nickname || o.username || "-"
return (
<TableRow key={o.id}>
<TableCell>
<div>
<div className="font-medium">{name}</div>
<div className="text-xs text-muted-foreground">#{o.display_id || "-"}</div>
</div>
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-xs">{o.out_trade_no}</TableCell>
<TableCell>{o.plan_label || "-"}</TableCell>
<TableCell className="whitespace-nowrap text-sm font-medium">{formatAmount(o.amount_cents)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{o.env_label}</TableCell>
<TableCell>{orderStatusBadge(o.status)}</TableCell>
<TableCell className="whitespace-nowrap font-mono text-xs text-muted-foreground">{o.wx_order_id || "-"}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(o.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{o.paid_at ? fmtTime(o.paid_at) : "-"}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<Pagination page={oq.data.page} totalPages={oq.data.total_pages} total={oq.data.total} onChange={setOrderPage} />
</div>
)}
</CardContent>
</Card>
)}
<StorageUserEditDialog user={editUser} onClose={() => setEditUser(null)} />
</div>
)
}
+17 -1
View File
@@ -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() {
<TableRow>
<TableHead>用户</TableHead><TableHead>性别</TableHead><TableHead>年龄</TableHead><TableHead>生日</TableHead>
<TableHead>注册时间</TableHead><TableHead>最后登录</TableHead><TableHead>登录地址</TableHead><TableHead>IP</TableHead>
<TableHead>状态</TableHead><TableHead>操作</TableHead>
<TableHead>存储空间</TableHead><TableHead>状态</TableHead><TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -99,6 +108,13 @@ export default function UsersPage() {
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(u.last_login_at)}</TableCell>
<TableCell className="max-w-36 truncate text-xs text-muted-foreground" title={u.last_login_location || u.location}>{u.last_login_location || u.location || "-"}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{u.last_ip || "-"}</TableCell>
<TableCell>
{u.storage_vip ? (
<Badge variant="default" title={u.storage_vip_expire_at ? `到期:${fmtTime(u.storage_vip_expire_at)}` : undefined}>♾️ 无限空间</Badge>
) : (
<span className="text-xs text-muted-foreground">{formatBytes(u.used_bytes)}</span>
)}
</TableCell>
<TableCell><Badge variant={u.couple_id ? "default" : "outline"}>{u.couple_id ? "💕 已绑定" : "💭 未绑定"}</Badge></TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1.5">
+1
View File
@@ -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",
+21 -14
View File
@@ -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)
});
}
});
});
},
+7 -3
View File
@@ -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] }); }
});
});
},
+11 -7
View File
@@ -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) });
}
});
});
},
+85 -29
View File
@@ -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"]}));
+21
View File
@@ -10,6 +10,27 @@
<view wx:if="{{bindingKnown && hasPartner}}" class="j-relation-card" bindtap="showEditPartnerModal"><view class="j-row"><view><text class="j-small-title">{{partnerName}}</text><text class="j-caption" wx:if="{{partnerDisplayId}}">ID {{partnerDisplayId}}</text><text class="j-caption">{{partnerDetail}}</text></view><view class="j-avatar"><image wx:if="{{partnerAvatarUrl}}" src="{{partnerAvatarUrl}}" mode="aspectFill" /><text wx:else>{{partnerInitial}}</text></view></view></view>
<view wx:elif="{{bindingKnown}}" class="j-invite" bindtap="showInvitePanel"><view><text class="j-small-title">另一半还没有加入</text><text class="j-caption">邀请TA,共写这本生活手账</text></view><text class="j-outline-label">绑定情侣</text></view>
<page-state wx:elif="{{bindingError}}" kind="error" title="绑定状态加载失败" action="重新加载" bind:action="loadData" /><page-state wx:else kind="loading" title="正在获取绑定状态" />
<view wx:if="{{storageVip}}" class="j-storage-card j-storage-vip" bindtap="goStorage">
<view class="j-storage-top">
<text class="j-storage-label">存储空间</text>
<text class="j-storage-val j-vip">👑 无限空间</text>
</view>
<view class="j-storage-bottom">
<text class="j-storage-hint">已用 {{storageUsedText}}{{storageExpireText ? ' · ' + storageExpireText : ''}}</text>
<text class="j-storage-cta">查看 ›</text>
</view>
</view>
<view wx:else class="j-storage-card" bindtap="goStorage">
<view class="j-storage-top">
<text class="j-storage-label">存储空间</text>
<text class="j-storage-val">{{storageText}}</text>
</view>
<view class="j-storage-bar"><view class="j-storage-fill" style="width:{{storagePercent}}%;"></view></view>
<view class="j-storage-bottom">
<text class="j-storage-hint">{{storageOverQuota ? '已超出免费空间,升级后可继续上传' : '已用 ' + storagePercent + '% 免费空间'}}</text>
<text class="j-storage-cta">{{storageOverQuota ? '升级无限空间 ›' : '查看 ›'}}</text>
</view>
</view>
<view class="profile-statistics">
<view class="profile-stat-grid">
<view class="profile-stat-item" wx:for="{{statisticItems}}" wx:key="key" data-key="{{item.key}}" bindtap="openStatistic" hover-class="profile-stat-pressed">
+18
View File
@@ -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 {
+34 -24
View File
@@ -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: '' });
}
});
});
},
+8 -4
View File
@@ -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: {
@@ -213,10 +214,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() {
+134
View File
@@ -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' });
}
});
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"app-nav": "/components/app-nav/app-nav",
"page-state": "/components/page-state/page-state"
},
"enablePullDownRefresh": true
}
+52
View File
@@ -0,0 +1,52 @@
<view class="app-shell app-shell--secondary j-handwritten-page" style="{{layoutStyle}}">
<launch-transition />
<!-- pages/storage/storage.wxml -->
<app-nav title="存储空间" back="{{true}}" />
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="j-body">
<!-- 已用空间 -->
<view class="storage-card">
<view class="storage-card-head">
<view class="storage-title">已用空间</view>
<view class="storage-vip" wx:if="{{isVip}}">👑 无限空间</view>
</view>
<view class="storage-used">
<text class="storage-num">{{usedText}}</text>
<text class="storage-of" wx:if="{{!isVip}}"> / {{freeText}} 免费</text>
</view>
<view class="storage-bar" wx:if="{{!isVip}}">
<view class="storage-bar-fill" style="width: {{percent}}%;"></view>
</view>
<view class="storage-hint" wx:if="{{isVip}}">{{vipExpireText}}</view>
<view class="storage-hint over" wx:elif="{{overQuota}}">已超出免费空间,升级后即可继续上传</view>
<view class="storage-hint" wx:else>已使用 {{percent}}% 免费空间</view>
</view>
<!-- 套餐 -->
<view class="section-heading">升级无限空间</view>
<view class="plan-list">
<view class="plan-card" wx:for="{{plans}}" wx:key="key" bindtap="buyPlan" data-key="{{item.key}}">
<view class="plan-info">
<view class="plan-name">{{item.name}}</view>
<view class="plan-desc">无限存储空间,照片视频想存就存</view>
</view>
<view class="plan-price">
<text class="plan-amount">¥{{item.priceText}}</text>
<text class="plan-unit">/{{item.unit}}</text>
</view>
</view>
</view>
<!-- 说明 -->
<view class="storage-note">
<view class="note-title">📌 说明</view>
<view class="note-item">• 默认提供 {{freeMb}}MB 免费空间</view>
<view class="note-item">• 开通会员后,照片、视频等不再限制存储空间</view>
<view class="note-item">• 会员到期后,已上传内容仍可正常查看</view>
</view>
<page-state wx:if="{{error}}" kind="error" title="加载失败" action="重试" bind:action="loadInfo" />
</view>
</scroll-view>
</view>
+157
View File
@@ -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';
+26
View File
@@ -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'),
+43
View File
@@ -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 };
+87
View File
@@ -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,再提交审核发布。