feat: 存储空间管理、虚拟支付接入与容量守卫
- 新增存储空间管理:会员套餐配置、用户容量/到期管理、后台支付记录查询 - 接入个人主体小程序虚拟支付:下单/发货推送/兜底查单(query_order) - 修复 adminStorageUsers 等接口鉴权、out_trade_no 长度、前端 requestVirtualPayment 参数 - 前端上传前容量预检,超容引导升级无限空间 - 我的页会员卡片展示「无限空间」与已用容量/到期时间 - 后台「存储空间管理」新增支付记录标签页
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(可选)
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 周,周日归上一个周一
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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;未绑定记录必须按用户和日期查找。
|
||||
|
||||
@@ -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)}}},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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隐藏'"},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "系统" },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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] }); }
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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"]}));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"app-nav": "/components/app-nav/app-nav",
|
||||
"page-state": "/components/page-state/page-state"
|
||||
},
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,再提交审核发布。
|
||||
Reference in New Issue
Block a user