feat: add WeChat Pay, Redis logging, storage tiers and personal albums
Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
This commit is contained in:
@@ -19,3 +19,9 @@ dist-ssr/
|
|||||||
# 系统文件
|
# 系统文件
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# 微信支付本地证书及私钥
|
||||||
|
past-go/cert/
|
||||||
|
|
||||||
|
# zap 日志及压缩轮转文件
|
||||||
|
log/
|
||||||
|
|||||||
@@ -1,18 +1,46 @@
|
|||||||
# 宝塔网站 → 设置 → 配置文件,把下面整段放到 server { } 内(或改用宝塔“反向代理”UI)
|
# Insert into the existing we.sundynix.cn HTTPS server block.
|
||||||
# 作用:把域名的所有请求反代到本地 Go 后端(:8080),由宝塔 Nginx 负责 HTTPS/SSL。
|
# Keep the existing SSL and ACME configuration. Replace matching locations.
|
||||||
|
location = / {
|
||||||
|
return 302 /backend/admin/login;
|
||||||
|
}
|
||||||
|
|
||||||
# 允许大文件上传(与 config.yaml 的 max_upload_mb 保持一致,默认 50MB)
|
location = /api {
|
||||||
client_max_body_size 50m;
|
client_max_body_size 60m;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
location / {
|
proxy_send_timeout 180s;
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
# 大文件/慢请求超时,与后端 ReadTimeout 对应
|
}
|
||||||
proxy_connect_timeout 60s;
|
location ^~ /api/ {
|
||||||
|
client_max_body_size 60m;
|
||||||
proxy_read_timeout 180s;
|
proxy_read_timeout 180s;
|
||||||
proxy_send_timeout 180s;
|
proxy_send_timeout 180s;
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
location ^~ /backend/ {
|
||||||
|
client_max_body_size 60m;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_send_timeout 180s;
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
location = /healthz {
|
||||||
|
client_max_body_size 60m;
|
||||||
|
proxy_read_timeout 180s;
|
||||||
|
proxy_send_timeout 180s;
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=past-go backend service
|
Description=Past Go API
|
||||||
After=network.target mysql.service
|
After=network-online.target postgresql.service redis-server.service
|
||||||
Wants=mysql.service
|
Wants=network-online.target
|
||||||
|
Requires=postgresql.service redis-server.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
# 运行用户:宝塔默认站点用户为 www,需对该用户有 data_dir/uploads 写权限
|
|
||||||
User=www
|
User=www
|
||||||
Group=www
|
Group=www
|
||||||
# 改为你实际上传二进制与 config.yaml 的目录
|
WorkingDirectory=/home/workspace
|
||||||
WorkingDirectory=/www/wwwroot/past
|
ExecStart=/home/workspace/past-server -config /home/workspace/config.yaml
|
||||||
ExecStart=/www/wwwroot/past/past-server -config /www/wwwroot/past/config.yaml
|
Environment=TZ=Asia/Shanghai
|
||||||
Restart=always
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
# 标准输出/错误会进 journalctl;程序自身也会写 data/go-server.log
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -4,7 +4,7 @@ GOOS ?= linux
|
|||||||
GOARCH ?= amd64
|
GOARCH ?= amd64
|
||||||
VERSION ?= $(shell date +%Y%m%d-%H%M)
|
VERSION ?= $(shell date +%Y%m%d-%H%M)
|
||||||
|
|
||||||
.PHONY: all web build build-windows build-local run dev test diff clean
|
.PHONY: all web build build-windows build-local run dev test clean
|
||||||
|
|
||||||
all: web build
|
all: web build
|
||||||
|
|
||||||
@@ -40,9 +40,5 @@ dev: build-local
|
|||||||
test:
|
test:
|
||||||
go vet ./... && go test ./...
|
go vet ./... && go test ./...
|
||||||
|
|
||||||
## PHP vs Go 响应对比(需先启动 dev 环境,见 ../dev/reset_dbs.sh)
|
|
||||||
diff:
|
|
||||||
cd ../dev && for s in scenarios/*.json; do ./reset_dbs.sh >/dev/null; python3 apidiff.py $$s | tail -1; done
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf dist run/$(BIN) web/admin/dist
|
rm -rf dist run/$(BIN) web/admin/dist
|
||||||
|
|||||||
+28
-57
@@ -1,3 +1,22 @@
|
|||||||
|
# 微信支付 API v3
|
||||||
|
|
||||||
|
空间会员通过微信小程序 `wx.requestPayment` 支付,服务端使用官方 `wechatpay-go` SDK(与 plant-go 同样采用微信支付公钥验签模式)。线上域名为 `we.sundynix.cn`。
|
||||||
|
|
||||||
|
配置文件中的 `wechat_pay` 包含商户号、证书序列号、APIv3 密钥、商户私钥、微信支付公钥 ID/路径与通知地址。设置 `enabled: true` 后启用;`appid` 留空时复用 `wechat.appid`,必须为商户已绑定的小程序。所有证书路径相对于所选 YAML 文件解析。
|
||||||
|
`certificate_path` 可选,填写后校验证书商户号、序列号、有效期及私钥匹配;请求签名只使用商户私钥。
|
||||||
|
本机证书放在已忽略 Git 的 `cert/`,真实密钥仅存在本地 `config.dev.yaml`,模板不含密钥。
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
- `POST /api/storageCreateOrder`:传 `planKey: month/year`;价格由服务端收费设置决定。先持久化订单,再调用预下单,返回 `outTradeNo` 与 `payment` 签名参数。
|
||||||
|
- `GET /api/storageOrderStatus?outTradeNo=...`:仅允许查询本人订单,必要时向微信主动查单并补开通。
|
||||||
|
- `POST https://we.sundynix.cn/api/wechatPayNotify`:无需用户登录,通过微信支付签名校验、APIv3 AES-GCM 解密后核对商户、AppID、付款人、金额和币种;数据库事务提交后返回 204。
|
||||||
|
|
||||||
|
订单保存支付身份和金额快照,并通过行锁、交易号检查实现重复通知幂等。会员已到期时从当前时间续期,未到期时累计续期;更新会员和订单状态在同一事务完成。服务每分钟补查最近 24 小时待支付订单,退出时停止。
|
||||||
|
管理端「存储空间管理」只配置免费额度和套餐价格,并查看支付状态与订单记录。小程序以服务端查单结果确认会员开通。
|
||||||
|
本地调试不能接收公网回调,但小程序支付后主动查单可以补开通;上线需让上述 HTTPS 回调能访问部署的新后端。
|
||||||
|
此通道为正式商户支付;真实支付需使用商户绑定 AppID 下的真实微信登录用户和真机。自动化测试使用签名及加密测试数据,不产生真实扣款。
|
||||||
|
|
||||||
# 本地 dev 调试
|
# 本地 dev 调试
|
||||||
|
|
||||||
在 `past-go/` 目录执行 `make dev`,使用 `config.dev.yaml` 启动,监听 `:8080`。
|
在 `past-go/` 目录执行 `make dev`,使用 `config.dev.yaml` 启动,监听 `:8080`。
|
||||||
@@ -8,8 +27,8 @@
|
|||||||
开发工具需关闭合法域名校验(项目已设置);真机调试将 dev 地址改为电脑的局域网 IP。
|
开发工具需关闭合法域名校验(项目已设置);真机调试将 dev 地址改为电脑的局域网 IP。
|
||||||
小程序 AppID 为 `wxb463820bf36dd5d6`,AppSecret 仅存放在后端本地配置。
|
小程序 AppID 为 `wxb463820bf36dd5d6`,AppSecret 仅存放在后端本地配置。
|
||||||
dev 继续使用当前 `past-go` 数据库与 MinIO bucket,并非独立测试数据。
|
dev 继续使用当前 `past-go` 数据库与 MinIO bucket,并非独立测试数据。
|
||||||
日志和缓存写入 `run/dev/data/`,定时补发通知关闭。
|
日志写入 `run/dev/log/YYYY-MM-DD/`,微信 access_token 缓存在 Redis,定时补发通知关闭。
|
||||||
新小程序的订阅模板和虚拟支付参数需在对应平台获取后填写,dev 配置暂时留空。
|
新小程序的订阅模板需在对应平台获取后填写。
|
||||||
|
|
||||||
# PostgreSQL / GORM 数据层(当前版本)
|
# PostgreSQL / GORM 数据层(当前版本)
|
||||||
|
|
||||||
@@ -44,64 +63,16 @@ MinIO 配置读取 `minio` 节点。`past_files` 保存 UUID、size、suffix、c
|
|||||||
|
|
||||||
真实 PostgreSQL/MinIO 回归:设置 `PAST_TEST_POSTGRES_DSN` 后运行 `go test ./...`。测试读取本地 `config.yaml` 中的 MinIO 连接,可通过 `PAST_TEST_CONFIG` 指定其他配置文件的绝对路径,使用隔离的 schema/bucket,测试结束清理;微信/地图外部请求使用模拟依赖。
|
真实 PostgreSQL/MinIO 回归:设置 `PAST_TEST_POSTGRES_DSN` 后运行 `go test ./...`。测试读取本地 `config.yaml` 中的 MinIO 连接,可通过 `PAST_TEST_CONFIG` 指定其他配置文件的绝对路径,使用隔离的 schema/bucket,测试结束清理;微信/地图外部请求使用模拟依赖。
|
||||||
|
|
||||||
以下保留历史说明,数据库配置与部署步骤以以上内容为准。
|
构建及部署:在 `past-go/` 目录执行 `make build-local` 生成本机服务;管理端资源已存在时可直接构建,修改管理端后先执行 `npm --prefix web/admin run build`。
|
||||||
|
Linux 发布使用 `make all`,Windows 发布使用 `make web build-windows`。
|
||||||
|
部署配置见 [deploy/README.md](deploy/README.md) 与 [Windows 部署说明](deploy/windows/README.md)。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 我们past后端(Go 版)
|
日志由独立 `internal/logging` 包使用 zap 管理,自动创建 `data_dir/log/YYYY-MM-DD/`,按级别保存 JSON 日志。默认保留 30 天,每个文件达到 100MB 轮转并压缩,备份数默认 10;可通过 `log` 配置调整。
|
||||||
|
|
||||||
`backend/api/index.php` 的 Go 重写:Gin + GORM + PostgreSQL,管理后台(React)通过 `embed` 打进同一个二进制。
|
Redis 是服务启动依赖,通过 `redis.addr/password/db/key_prefix` 配置;本地、测试和生产应使用不同前缀或 DB。微信 token 按 AppID 缓存,按 `expires_in` 提前过期,通过 Redis 锁协调多实例刷新,不再读写 `data/access_token.cache`。Redis 不可用时启动失败。
|
||||||
小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。
|
|
||||||
|
|
||||||
本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。
|
新数据库的管理员账号密码为 `past / past`。已有数据库可显式执行 `./past-server -config config.yaml -reset-admin` 更新为该账号密码,保留原 `admin` 的 ID;正常启动不会重置已修改的密码。
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
```
|
存储收费支持多档套餐,在管理端「存储空间 → 存储收费设置」编辑名称、容量 MB(0 为无限)、有效月数、价格、上架状态和排序。套餐变更原子保存并检查配置版本,已购买权益及历史订单不随套餐修改或下架改变。新表 `past_storage_plan` 保存套餐,`past_storage_entitlement` 保存每笔权益快照;同容量续费顺延,不同容量各自到期,用户与当前伴侣共享最大有效容量,有限套餐不会绕过上传限制。到期后已有文件可查看,新增上传按剩余有效容量检查。
|
||||||
cmd/server/ 入口
|
|
||||||
internal/config/ config.yaml 读取
|
|
||||||
internal/store/ GORM 数据访问、PostgreSQL 自动迁移与逻辑删除
|
|
||||||
internal/httpapi/ gin 路由、Ctx($_GET/$_POST/$input/$_FILES 等价物)、鉴权
|
|
||||||
internal/handler/ 121 个 action,按模块拆分(user/couple/timeline/album/diary/songs/checkin/period/pet/misc/admin)
|
|
||||||
internal/upload/ MinIO 上传、MD5 去重、日期目录与签名访问
|
|
||||||
internal/wechat/ access_token / 内容安全 / 订阅消息 / jscode2session
|
|
||||||
internal/geo/ 腾讯位置服务
|
|
||||||
internal/notify/ cron_notify.php 的进程内定时版
|
|
||||||
web/admin/ React 管理后台(dist/ 被 embed)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 运行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp config.example.yaml config.yaml # 填写数据库 / 微信配置
|
|
||||||
go build -o past-server ./cmd/server
|
|
||||||
./past-server -config config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
运行目录布局(`data_dir`,默认为二进制所在目录):
|
|
||||||
|
|
||||||
```
|
|
||||||
data_dir/
|
|
||||||
├── uploads/ 用户上传文件(从 PHP 的 backend/uploads 原样迁移)
|
|
||||||
└── data/ access_token.cache 等
|
|
||||||
```
|
|
||||||
|
|
||||||
URL 映射:
|
|
||||||
|
|
||||||
| URL | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `/api/接口名` | 小程序、管理后台、上传统一入口 |
|
|
||||||
| `/backend/api/index.php?action=…` | 旧客户端兼容入口 |
|
|
||||||
| `/backend/uploads/…` | 上传文件静态服务(对应 DB 中 `uploads/…` 路径) |
|
|
||||||
| `/backend/admin/` | 管理后台 SPA |
|
|
||||||
| `/healthz` | 健康检查 |
|
|
||||||
|
|
||||||
## 与 PHP 版的差异(有意为之)
|
|
||||||
|
|
||||||
见 `../dev/API_CONTRACT.md` 与对比测试 `../dev/apidiff.py`。已知的 PHP 原有 bug 处理:
|
|
||||||
|
|
||||||
- `*_add` 接口返回的 `id` 改为真实记录 id(PHP 返回的是 activity_log 的 id)。
|
|
||||||
- `pet_dynamics` 分页可用(PHP 因 `LIMIT '20'` 语法错误恒 500)。
|
|
||||||
- 宠物成就解锁可用(PHP 因 `pet_dynamics.user_id=0` 违反外键恒 500)。
|
|
||||||
- `logout` 会真正失效 token;登录不再踢掉其它设备的 token。
|
|
||||||
- 数据库迁移只在启动时执行一次(PHP 每次请求执行)。
|
|
||||||
|
|||||||
+60
-31
@@ -5,8 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"go.uber.org/zap"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -15,14 +14,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
_ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用
|
_ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用
|
||||||
|
|
||||||
|
"past/internal/cache"
|
||||||
"past/internal/config"
|
"past/internal/config"
|
||||||
"past/internal/geo"
|
"past/internal/geo"
|
||||||
"past/internal/handler"
|
"past/internal/handler"
|
||||||
"past/internal/httpapi"
|
"past/internal/httpapi"
|
||||||
|
"past/internal/logging"
|
||||||
"past/internal/notify"
|
"past/internal/notify"
|
||||||
"past/internal/store"
|
"past/internal/store"
|
||||||
"past/internal/upload"
|
"past/internal/upload"
|
||||||
"past/internal/wechat"
|
"past/internal/wechat"
|
||||||
|
"past/internal/wxpay"
|
||||||
"past/web"
|
"past/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ var version = "dev"
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfgPath := flag.String("config", "config.yaml", "配置文件路径")
|
cfgPath := flag.String("config", "config.yaml", "配置文件路径")
|
||||||
|
resetAdmin := flag.Bool("reset-admin", false, "将管理端账号密码设置为 past/past 后退出")
|
||||||
showVer := flag.Bool("version", false, "显示版本")
|
showVer := flag.Bool("version", false, "显示版本")
|
||||||
svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop")
|
svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -55,6 +58,26 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *resetAdmin {
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err == nil {
|
||||||
|
var db *store.DB
|
||||||
|
db, err = store.Open(cfg.DSN())
|
||||||
|
if err == nil {
|
||||||
|
err = db.ResetDefaultAdmin()
|
||||||
|
if pool, e := db.ORM.DB(); e == nil {
|
||||||
|
_ = pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "管理员重置失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("管理员账号已设置为 past")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) {
|
if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -78,46 +101,52 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
|||||||
|
|
||||||
cfg, err := config.Load(cfgPath)
|
cfg, err := config.Load(cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("配置加载失败", "err", err)
|
fmt.Fprintln(os.Stderr, "配置加载失败:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// 确保必要目录存在(对应 config.php 中的 $dirs)
|
log, closeLog, err := logging.New(cfg.LogDir(), cfg.Log)
|
||||||
for _, d := range []string{"data", "data/tmp"} {
|
if err != nil {
|
||||||
os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755)
|
fmt.Fprintln(os.Stderr, "日志初始化失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径)
|
defer closeLog()
|
||||||
logOut := io.Writer(os.Stdout)
|
undoZap := zap.ReplaceGlobals(log)
|
||||||
if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
defer undoZap()
|
||||||
logOut = io.MultiWriter(os.Stdout, f)
|
undoStdLog := zap.RedirectStdLog(log)
|
||||||
defer f.Close()
|
defer undoStdLog()
|
||||||
|
redisClient, err := cache.Open(cfg.Redis)
|
||||||
|
if err != nil {
|
||||||
|
zap.S().Errorw("Redis 初始化失败", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
defer redisClient.Close()
|
||||||
|
zap.L().Info("Redis 已连接")
|
||||||
|
|
||||||
if err := store.EnsureDatabase(cfg.DSN()); err != nil {
|
if err := store.EnsureDatabase(cfg.DSN()); err != nil {
|
||||||
slog.Error("创建 PostgreSQL 数据库失败", "err", err)
|
zap.S().Errorw("创建 PostgreSQL 数据库失败", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
db, err := store.Open(cfg.DSN())
|
db, err := store.Open(cfg.DSN())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("数据库连接失败", "err", err)
|
zap.S().Errorw("数据库连接失败", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
db.NumericAsString = cfg.PHPCompat < "8.1"
|
db.NumericAsString = cfg.PHPCompat < "8.1"
|
||||||
if err := db.InitSchema(); err != nil {
|
if err := db.InitSchema(); err != nil {
|
||||||
slog.Error("数据库初始化失败", "err", err)
|
zap.S().Errorw("数据库初始化失败", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.MigrateSmokingRecords(); err != nil {
|
if err := db.MigrateSmokingRecords(); err != nil {
|
||||||
slog.Error("抽烟库存迁移失败", "err", err)
|
zap.S().Errorw("抽烟库存迁移失败", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if err := db.AdvanceUnbindings(); err != nil {
|
if err := db.AdvanceUnbindings(); err != nil {
|
||||||
slog.Error("解绑状态处理失败", "err", err)
|
zap.S().Errorw("解绑状态处理失败", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
go db.RunUnbindingWorker()
|
go db.RunUnbindingWorker()
|
||||||
wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db)
|
wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, redisClient, cfg.Redis.KeyPrefix, db)
|
||||||
app := &httpapi.App{
|
app := &httpapi.App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -125,27 +154,27 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
|||||||
Geo: geo.New(cfg.TencentMapKey),
|
Geo: geo.New(cfg.TencentMapKey),
|
||||||
Admin: httpapi.NewAdminSessions(),
|
Admin: httpapi.NewAdminSessions(),
|
||||||
}
|
}
|
||||||
|
if cfg.WechatPay.Enabled {
|
||||||
|
app.WechatPay, err = wxpay.New(cfg.WechatPay)
|
||||||
|
if err != nil {
|
||||||
|
zap.S().Errorw("微信支付初始化失败", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
go handler.RunWechatOrderQuery(db, app.WechatPay, stop, time.Minute)
|
||||||
|
}
|
||||||
uploader, err := upload.New(cfg.Minio, db, cfg.MaxUploadMB*1024*1024, wx)
|
uploader, err := upload.New(cfg.Minio, db, cfg.MaxUploadMB*1024*1024, wx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("MinIO 初始化失败", "err", err)
|
zap.S().Errorw("MinIO 初始化失败", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := httpapi.NewRouter(app, web.AdminFS())
|
router := httpapi.NewRouter(app, web.AdminFS())
|
||||||
handler.Register(router, uploader)
|
handler.Register(router, uploader)
|
||||||
slog.Info("已注册 action", "count", len(router.Actions()))
|
zap.S().Infow("已注册 action", "count", len(router.Actions()))
|
||||||
|
|
||||||
if cfg.NotifyIntervalMin > 0 {
|
if cfg.NotifyIntervalMin > 0 {
|
||||||
go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute)
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Listen,
|
Addr: cfg.Listen,
|
||||||
@@ -156,9 +185,9 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
|||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir)
|
zap.S().Infow("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
slog.Error("服务异常退出", "err", err)
|
zap.S().Errorw("服务异常退出", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -167,5 +196,5 @@ func serve(cfgPath string, stop <-chan struct{}) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
srv.Shutdown(ctx)
|
srv.Shutdown(ctx)
|
||||||
slog.Info("服务已停止")
|
zap.S().Infow("服务已停止")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func serviceControl(cmd, cfgPath string) error {
|
|||||||
}
|
}
|
||||||
s, err := m.CreateService(svcName, exe, mgr.Config{
|
s, err := m.CreateService(svcName, exe, mgr.Config{
|
||||||
DisplayName: "我们past Go 服务 (past-server)",
|
DisplayName: "我们past Go 服务 (past-server)",
|
||||||
Description: "past.sundynix.cn 后端 API 与管理后台",
|
Description: "we.sundynix.cn 后端 API 与管理后台",
|
||||||
StartType: mgr.StartAutomatic,
|
StartType: mgr.StartAutomatic,
|
||||||
}, "-config", abs)
|
}, "-config", abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# 本地 dev 配置(模板,复制为 config.dev.yaml 后补充密钥)
|
# 本地 dev 配置(模板,复制为 config.dev.yaml 后补充密钥)
|
||||||
listen: ":8080"
|
listen: ":8080"
|
||||||
# 本地日志与缓存独立目录;文件存储使用下方 MinIO
|
# 本地运行目录:log/ 存日志,data/tmp/ 存临时文件;文件存储使用 MinIO
|
||||||
data_dir: "./run/dev"
|
data_dir: "./run/dev"
|
||||||
debug: true
|
debug: true
|
||||||
php_compat: "8.0"
|
|
||||||
db:
|
db:
|
||||||
host: 192.168.100.127
|
host: 192.168.100.127
|
||||||
port: 5432
|
port: 5432
|
||||||
@@ -19,19 +18,36 @@ tencent_map_key: ""
|
|||||||
max_upload_mb: 50
|
max_upload_mb: 50
|
||||||
# 定时补发通知间隔(分钟),0 关闭
|
# 定时补发通知间隔(分钟),0 关闭
|
||||||
notify_interval_min: 0 # 本地调试关闭定时补发
|
notify_interval_min: 0 # 本地调试关闭定时补发
|
||||||
# 微信小程序虚拟支付(米大师虚拟支付2.0)
|
|
||||||
virtual_pay:
|
|
||||||
appid: wxb463820bf36dd5d6
|
|
||||||
offer_id: "" # 待填写新 AppID 对应的支付应用 ID
|
|
||||||
app_key: ""
|
|
||||||
sandbox_key: "" # 待填写新应用的沙箱 AppKey
|
|
||||||
env: 1
|
|
||||||
notify_token: ""
|
|
||||||
notify_aes_key: ""
|
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
endpoint: https://s3.sundynix.cn
|
endpoint: https://s3.sundynix.cn
|
||||||
access_key: your_minio_access_key
|
access_key: your_minio_access_key
|
||||||
secret_key: your_minio_secret_key
|
secret_key: your_minio_secret_key
|
||||||
bucket: past-go
|
bucket: past-go
|
||||||
use_ssl: true
|
use_ssl: true
|
||||||
|
|
||||||
|
# 微信支付 API v3(直连商户 / 小程序 JSAPI)
|
||||||
|
wechat_pay:
|
||||||
|
enabled: false
|
||||||
|
appid: "wxb463820bf36dd5d6"
|
||||||
|
mch_id: "your_mch_id"
|
||||||
|
certificate_serial: "your_certificate_serial"
|
||||||
|
certificate_path: "" # 可选:填写后启动时校验证书、商户号及私钥的一致性
|
||||||
|
private_key_path: "cert/apiclient_key.pem"
|
||||||
|
public_key_id: "your_wechatpay_public_key_id"
|
||||||
|
public_key_path: "cert/pub_key.pem"
|
||||||
|
api_v3_key: ""
|
||||||
|
notify_url: "https://we.sundynix.cn/api/wechatPayNotify"
|
||||||
|
|
||||||
|
# Redis 缓存微信 token;log/ 按日期和级别落盘
|
||||||
|
redis:
|
||||||
|
addr: 127.0.0.1:6379
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
db: 0
|
||||||
|
key_prefix: "past-go:"
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
console: true
|
||||||
|
retention_days: 30
|
||||||
|
max_size_mb: 100
|
||||||
|
max_backups: 10
|
||||||
|
|||||||
+27
-11
@@ -17,20 +17,36 @@ tencent_map_key: ""
|
|||||||
max_upload_mb: 50
|
max_upload_mb: 50
|
||||||
# 定时补发通知间隔(分钟),0 关闭
|
# 定时补发通知间隔(分钟),0 关闭
|
||||||
notify_interval_min: 5
|
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(可选)
|
|
||||||
|
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
endpoint: https://s3.sundynix.cn
|
endpoint: https://s3.sundynix.cn
|
||||||
access_key: your_minio_access_key
|
access_key: your_minio_access_key
|
||||||
secret_key: your_minio_secret_key
|
secret_key: your_minio_secret_key
|
||||||
bucket: past-go
|
bucket: past-go
|
||||||
use_ssl: true
|
use_ssl: true
|
||||||
|
|
||||||
|
# 微信支付 API v3(直连商户 / 小程序 JSAPI)
|
||||||
|
wechat_pay:
|
||||||
|
enabled: false
|
||||||
|
appid: "wxb463820bf36dd5d6"
|
||||||
|
mch_id: "your_mch_id"
|
||||||
|
certificate_serial: "your_certificate_serial"
|
||||||
|
certificate_path: "" # 可选:填写后启动时校验证书、商户号及私钥的一致性
|
||||||
|
private_key_path: "cert/apiclient_key.pem"
|
||||||
|
public_key_id: "your_wechatpay_public_key_id"
|
||||||
|
public_key_path: "cert/pub_key.pem"
|
||||||
|
api_v3_key: ""
|
||||||
|
notify_url: "https://we.sundynix.cn/api/wechatPayNotify"
|
||||||
|
|
||||||
|
# Redis 缓存微信 token;log/ 按日期和级别落盘
|
||||||
|
redis:
|
||||||
|
addr: 127.0.0.1:6379
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
db: 0
|
||||||
|
key_prefix: "past-go:"
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
console: true
|
||||||
|
retention_days: 30
|
||||||
|
max_size_mb: 100
|
||||||
|
max_backups: 10
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ Go 全套和前端回归在本机执行;尚未在生产数据库或微信真
|
|||||||
2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。
|
2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。
|
||||||
3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。
|
3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。
|
||||||
4. 检查并重新加载 Nginx,已有配置不变时可以跳过。若调整配置,在你当前 Nginx 机器上检查后重新加载。
|
4. 检查并重新加载 Nginx,已有配置不变时可以跳过。若调整配置,在你当前 Nginx 机器上检查后重新加载。
|
||||||
5. 浏览器访问 `https://past.sundynix.cn/api` 应返回 API 名称与版本。未登录访问 `/api/coupleInfo` 应返回 401 JSON,不能是 Nginx HTML 404。
|
5. 浏览器访问 `https://we.sundynix.cn/api` 应返回 API 名称与版本。未登录访问 `/api/coupleInfo` 应返回 401 JSON,不能是 Nginx HTML 404。
|
||||||
6. 管理后台仍从 `/backend/admin/` 进入,新后台已嵌入程序,刷新页面、重新登录后确认 Network 请求为 `/api/adminStats` 等驼峰路径。
|
6. 管理后台仍从 `/backend/admin/` 进入,新后台已嵌入程序,刷新页面、重新登录后确认 Network 请求为 `/api/adminStats` 等驼峰路径。
|
||||||
7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://past.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。
|
7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://we.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。
|
||||||
|
|
||||||
## 接口规则
|
## 接口规则
|
||||||
|
|
||||||
@@ -85,3 +85,14 @@ Go 全套和前端回归在本机执行;尚未在生产数据库或微信真
|
|||||||
## 本次 UI 精修
|
## 本次 UI 精修
|
||||||
|
|
||||||
小程序首页聚焦双人天数、四个入口和最近记录;记录页分组;日记、相册使用标题旁操作按钮;姨姨选中日期后展开当天操作;个人中心与设置分组。管理后台统一侧栏、页头、统计卡、列表、表格、弹层与登录页,沿用现有组件库。
|
小程序首页聚焦双人天数、四个入口和最近记录;记录页分组;日记、相册使用标题旁操作按钮;姨姨选中日期后展开当天操作;个人中心与设置分组。管理后台统一侧栏、页头、统计卡、列表、表格、弹层与登录页,沿用现有组件库。
|
||||||
|
|
||||||
|
|
||||||
|
## Redis 与 zap(Linux)
|
||||||
|
|
||||||
|
服务器需安装 Redis 并启用 `redis-server.service`。Redis 仅监听本机 `127.0.0.1:6379` / `::1:6379`,服务配置通过 `redis.addr`、`password`、`db` 和 `key_prefix` 指定。生产前缀为 `past-go:prod:`,避免与开发缓存混用。systemd 服务依赖 PostgreSQL 与 Redis,更新后执行 `systemctl daemon-reload` 和 `systemctl restart past-go`。
|
||||||
|
|
||||||
|
`data_dir: /home/workspace` 时,zap 自动创建 `/home/workspace/log/YYYY-MM-DD/`,按级别记录 JSON 日志,HTTP 访问日志不记录请求正文、认证头或查询参数。保留天数、文件大小、压缩备份数量由 `log` 配置指定。旧 `data/go-server.log` 与 `data/access_token.cache` 不再读写。
|
||||||
|
|
||||||
|
若 Ubuntu Redis 提示 `libjemalloc.so.2: failed to map segment`,先检查 `ldd /usr/bin/redis-server`:当前服务器的 `/usr/local/lib` 中有同名库,通过 Redis 的 systemd drop-in 设置 `Environment=LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu` 使用系统自带库,保留原有 `NoExecPaths` 等沙箱限制。
|
||||||
|
|
||||||
|
已有管理员可通过 `past-server -config config.yaml -reset-admin` 显式重置为 `past / past`;新数据库也使用该默认账号。普通启动不会重置密码。
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 放在 past.sundynix.cn 现有 HTTPS server 块里。
|
# 放在 we.sundynix.cn 现有 HTTPS server 块里。
|
||||||
# proxy_pass 末尾不要加 /,需要保留 /api/接口名。
|
# proxy_pass 末尾不要加 /,需要保留 /api/接口名。
|
||||||
|
location = / {
|
||||||
|
return 302 /backend/admin/login;
|
||||||
|
}
|
||||||
|
|
||||||
location = /api {
|
location = /api {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ windows/
|
|||||||
## 部署步骤
|
## 部署步骤
|
||||||
|
|
||||||
1. **准备数据库**:MySQL 建库 `past`、创建用户,确保服务所在机器可连接。
|
1. **准备数据库**:MySQL 建库 `past`、创建用户,确保服务所在机器可连接。
|
||||||
2. **填写配置**:编辑 `config.yaml`(数据库、微信、虚拟支付等)。
|
2. **填写配置**:编辑 `config.yaml`(数据库、微信、微信支付 API v3 等)。
|
||||||
- 首次安装:复制 `config.example.yaml` 为 `config.yaml` 后填写。
|
- 首次安装:复制 `config.example.yaml` 为 `config.yaml` 后填写。
|
||||||
- **确认 `listen` 端口与 Nginx 转发端口一致**(`config.example.yaml` 示例为 `:8080`,
|
- **确认 `listen` 端口与 Nginx 转发端口一致**(`config.example.yaml` 示例为 `:8080`,
|
||||||
`../nginx-api.conf` 按 `127.0.0.1:8081` 转发,二者需保持一致)。
|
`../nginx-api.conf` 按 `127.0.0.1:8081` 转发,二者需保持一致)。
|
||||||
|
|||||||
+9
-1
@@ -3,11 +3,17 @@ module past
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alicebob/miniredis/v2 v2.40.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/minio/minio-go/v7 v7.4.1
|
github.com/minio/minio-go/v7 v7.4.1
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0
|
||||||
|
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||||
|
go.uber.org/zap v1.28.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.48.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/postgres v1.6.3
|
gorm.io/driver/postgres v1.6.3
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
@@ -27,7 +33,6 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
@@ -51,8 +56,11 @@ require (
|
|||||||
github.com/tinylib/msgp v1.6.4 // indirect
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.uber.org/atomic v1.12.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.60.0 // indirect
|
golang.org/x/net v0.60.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
|
||||||
|
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
@@ -87,6 +95,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
|
||||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
@@ -99,6 +109,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
@@ -110,14 +121,26 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
|
||||||
|
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
|
||||||
|
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
@@ -139,6 +162,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
|||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
// Package cache initializes the shared Redis connection.
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"past/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(cfg config.RedisConfig) (*redis.Client, error) {
|
||||||
|
if strings.TrimSpace(cfg.Addr) == "" || strings.TrimSpace(cfg.KeyPrefix) == "" || cfg.DB < 0 {
|
||||||
|
return nil, fmt.Errorf("Redis addr、key_prefix 不能为空,db 必须非负")
|
||||||
|
}
|
||||||
|
client := redis.NewClient(&redis.Options{Addr: cfg.Addr, Username: cfg.Username, Password: cfg.Password, DB: cfg.DB, DialTimeout: 3 * time.Second, ReadTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second, MaxRetries: 1, ContextTimeoutEnabled: true})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
return nil, fmt.Errorf("Redis 连接失败: %w", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,22 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type RedisConfig struct {
|
||||||
|
Addr string `yaml:"addr"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
DB int `yaml:"db"`
|
||||||
|
KeyPrefix string `yaml:"key_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `yaml:"level"`
|
||||||
|
Console bool `yaml:"console"`
|
||||||
|
RetentionDays int `yaml:"retention_days"`
|
||||||
|
MaxSizeMB int `yaml:"max_size_mb"`
|
||||||
|
MaxBackups int `yaml:"max_backups"`
|
||||||
|
}
|
||||||
|
|
||||||
type MinioConfig struct {
|
type MinioConfig struct {
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
AccessKey string `yaml:"access_key"`
|
AccessKey string `yaml:"access_key"`
|
||||||
@@ -21,13 +37,37 @@ type MinioConfig struct {
|
|||||||
UseSSL bool `yaml:"use_ssl"`
|
UseSSL bool `yaml:"use_ssl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WechatPayConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
AppID string `yaml:"appid"`
|
||||||
|
MchID string `yaml:"mch_id"`
|
||||||
|
CertificateSerial string `yaml:"certificate_serial"`
|
||||||
|
CertificatePath string `yaml:"certificate_path"`
|
||||||
|
APIv3Key string `yaml:"api_v3_key"`
|
||||||
|
PrivateKeyPath string `yaml:"private_key_path"`
|
||||||
|
PublicKeyID string `yaml:"public_key_id"`
|
||||||
|
PublicKeyPath string `yaml:"public_key_path"`
|
||||||
|
NotifyURL string `yaml:"notify_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c WechatPayConfig) ValidateNotifyURL() error {
|
||||||
|
u, err := url.Parse(c.NotifyURL)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return fmt.Errorf("微信支付 notify_url 必须为公网 HTTPS 地址,且不含查询参数")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Redis RedisConfig `yaml:"redis"`
|
||||||
|
Log LogConfig `yaml:"log"`
|
||||||
|
WechatPay WechatPayConfig `yaml:"wechat_pay"`
|
||||||
Minio MinioConfig `yaml:"minio"`
|
Minio MinioConfig `yaml:"minio"`
|
||||||
|
|
||||||
Listen string `yaml:"listen"` // 监听地址,如 ":8080"
|
Listen string `yaml:"listen"` // 监听地址,如 ":8080"
|
||||||
Debug bool `yaml:"debug"` // gin debug 模式(打印路由等)
|
Debug bool `yaml:"debug"` // gin debug 模式(打印路由等)
|
||||||
|
|
||||||
// DataDir 运行数据目录:data/ 存日志与缓存;新增上传文件保存在 MinIO。
|
// DataDir 运行目录:log/ 存日志,data/tmp/ 存临时文件;文件存储在 MinIO。
|
||||||
// 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。
|
// 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。
|
||||||
DataDir string `yaml:"data_dir"`
|
DataDir string `yaml:"data_dir"`
|
||||||
|
|
||||||
@@ -57,21 +97,12 @@ type Config struct {
|
|||||||
|
|
||||||
// Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭
|
// Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭
|
||||||
NotifyIntervalMin int `yaml:"notify_interval_min"`
|
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) {
|
func Load(path string) (*Config, error) {
|
||||||
c := &Config{}
|
c := &Config{}
|
||||||
|
c.Redis = RedisConfig{Addr: "127.0.0.1:6379", KeyPrefix: "past-go:"}
|
||||||
|
c.Log = LogConfig{Level: "info", Console: true, RetentionDays: 30, MaxSizeMB: 100, MaxBackups: 10}
|
||||||
c.Listen = ":8080"
|
c.Listen = ":8080"
|
||||||
c.DB.Host = "192.168.100.127"
|
c.DB.Host = "192.168.100.127"
|
||||||
c.DB.Port = 5432
|
c.DB.Port = 5432
|
||||||
@@ -83,7 +114,6 @@ func Load(path string) (*Config, error) {
|
|||||||
c.NotifyIntervalMin = 5
|
c.NotifyIntervalMin = 5
|
||||||
c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8"
|
c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8"
|
||||||
c.PHPCompat = "8.0"
|
c.PHPCompat = "8.0"
|
||||||
c.VirtualPay.Env = 1
|
|
||||||
|
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,6 +122,18 @@ func Load(path string) (*Config, error) {
|
|||||||
if err := yaml.Unmarshal(b, c); err != nil {
|
if err := yaml.Unmarshal(b, c); err != nil {
|
||||||
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
|
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
if c.WechatPay.AppID == "" {
|
||||||
|
c.WechatPay.AppID = c.Wechat.AppID
|
||||||
|
}
|
||||||
|
if c.WechatPay.Enabled && c.WechatPay.AppID != c.Wechat.AppID {
|
||||||
|
return nil, fmt.Errorf("微信支付 AppID 必须与微信登录 AppID 一致")
|
||||||
|
}
|
||||||
|
// Certificate paths are relative to the selected YAML file, independent of cwd.
|
||||||
|
for _, p := range []*string{&c.WechatPay.PrivateKeyPath, &c.WechatPay.PublicKeyPath, &c.WechatPay.CertificatePath} {
|
||||||
|
if *p != "" && !filepath.IsAbs(*p) {
|
||||||
|
*p = filepath.Join(filepath.Dir(path), *p)
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.DataDir == "" {
|
if c.DataDir == "" {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,7 +150,7 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) UploadDir() string { return filepath.Join(c.DataDir, "uploads") }
|
func (c *Config) UploadDir() string { return filepath.Join(c.DataDir, "uploads") }
|
||||||
func (c *Config) LogDir() string { return filepath.Join(c.DataDir, "data") }
|
func (c *Config) LogDir() string { return filepath.Join(c.DataDir, "log") }
|
||||||
|
|
||||||
// DSN uses URL escaping for database names, passwords and IPv6 hosts.
|
// DSN uses URL escaping for database names, passwords and IPv6 hosts.
|
||||||
func (c *Config) DSN() string {
|
func (c *Config) DSN() string {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,13 +29,35 @@ func TestDevExampleConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if c.Wechat.AppID != "wxb463820bf36dd5d6" || c.Wechat.Secret != "" || c.VirtualPay.AppID != c.Wechat.AppID {
|
if c.Wechat.AppID != "wxb463820bf36dd5d6" || c.Wechat.Secret != "" {
|
||||||
t.Fatal("dev template must use the new AppID and keep AppSecret local")
|
t.Fatal("dev template must use the new AppID and keep AppSecret local")
|
||||||
}
|
}
|
||||||
if c.Listen != ":8080" || !c.Debug || c.NotifyIntervalMin != 0 || c.DB.Name != "past-go" || c.Minio.Bucket != "past-go" {
|
if c.Listen != ":8080" || !c.Debug || c.NotifyIntervalMin != 0 || c.DB.Name != "past-go" || c.Minio.Bucket != "past-go" {
|
||||||
t.Fatal("dev service configuration is incomplete")
|
t.Fatal("dev service configuration is incomplete")
|
||||||
}
|
}
|
||||||
if c.VirtualPay.OfferID != "" || c.VirtualPay.SandboxKey != "" || c.Wechat.NotifyTemplateID != "" {
|
if c.Wechat.NotifyTemplateID != "" {
|
||||||
t.Fatal("dev configuration must not reuse old app payment or subscription credentials")
|
t.Fatal("dev configuration must not reuse old app payment or subscription credentials")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWechatPayPathsFollowConfigurationDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
data := []byte("wechat:\n appid: wx-test\nwechat_pay:\n enabled: true\n private_key_path: cert/key.pem\n public_key_path: cert/pub.pem\n")
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.WechatPay.AppID != "wx-test" || cfg.WechatPay.PrivateKeyPath != filepath.Join(dir, "cert/key.pem") || cfg.WechatPay.PublicKeyPath != filepath.Join(dir, "cert/pub.pem") || cfg.WechatPay.CertificatePath != "" {
|
||||||
|
t.Fatal("incorrect payment configuration path or default AppID")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, append(data, []byte(" appid: different-app\n")...), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("payment AppID mismatching login accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -404,8 +404,8 @@ func adminChangePassword(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(c.InStr("new_password")) < 6 {
|
if len(c.InStr("new_password")) < 4 {
|
||||||
return c.Err(400, "新密码至少6位")
|
return c.Err(400, "新密码至少4位")
|
||||||
}
|
}
|
||||||
sess := c.AdminSession()
|
sess := c.AdminSession()
|
||||||
admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID)
|
admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID)
|
||||||
|
|||||||
@@ -34,15 +34,22 @@ func coupleSpaceID(c *httpapi.Ctx, cid string) string {
|
|||||||
return row.Str("couple_space_id")
|
return row.Str("couple_space_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Album access follows the creator: personal before binding, shared with the current partner afterwards.
|
||||||
|
func albumUserIds(c *httpapi.Ctx) []string {
|
||||||
|
if ids := coupleUserIds(c, ""); len(ids) == 2 {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
return []string{c.UID(), c.UID()}
|
||||||
|
}
|
||||||
|
func albumActivity(c *httpapi.Ctx, kind, description string) string {
|
||||||
|
if c.CoupleID() == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return logActivity(c.DB, c.CoupleID(), c.UID(), kind, description, "")
|
||||||
|
}
|
||||||
|
|
||||||
func albumList(c *httpapi.Ctx) error {
|
func albumList(c *httpapi.Ctx) error {
|
||||||
ids := coupleUserIds(c, "")
|
ids := albumUserIds(c)
|
||||||
if len(ids) == 0 {
|
|
||||||
photoTotal := int64(0)
|
|
||||||
for _, album := range demoData("album") {
|
|
||||||
photoTotal += store.ToInt(album["photo_count"])
|
|
||||||
}
|
|
||||||
return demoListResponse(c, "album", M{"photo_total": photoTotal})
|
|
||||||
}
|
|
||||||
suffix, window := listWindow(c)
|
suffix, window := listWindow(c)
|
||||||
items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC, a.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...)
|
items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC, a.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,11 +70,12 @@ func albumAdd(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cid, err := requireCoupleID(c)
|
cid := c.CoupleID()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
uid := c.UID()
|
uid := c.UID()
|
||||||
|
var albumCouple any
|
||||||
|
if cid != "" {
|
||||||
|
albumCouple = cid
|
||||||
|
}
|
||||||
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
|
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -77,12 +85,12 @@ func albumAdd(c *httpapi.Ctx) error {
|
|||||||
location = c.App.Geo.ReverseGeocode(lat, lng)
|
location = c.App.Geo.ReverseGeocode(lat, lng)
|
||||||
}
|
}
|
||||||
id, err := c.DB.Insert("INSERT INTO albums (couple_id, title, description, cover, sort_order, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
id, err := c.DB.Insert("INSERT INTO albums (couple_id, title, description, cover, sort_order, lat, lng, location, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
cid, c.In("title"), inOr(c, "description", ""), inOr(c, "cover", ""), inOr(c, "sort_order", 0), lat, lng, location, uid)
|
albumCouple, c.In("title"), inOr(c, "description", ""), inOr(c, "cover", ""), inOr(c, "sort_order", 0), lat, lng, location, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
title := c.InStr("title")
|
title := c.InStr("title")
|
||||||
actID := logActivity(c.DB, cid, uid, "album_create", "创建了相册「"+title+"」", "")
|
actID := albumActivity(c, "album_create", "创建了相册「"+title+"」")
|
||||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||||
"thing1": "新相册「" + mbTruncate(title, 20) + "」",
|
"thing1": "新相册「" + mbTruncate(title, 20) + "」",
|
||||||
@@ -98,10 +106,8 @@ func albumUpdate(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ids, err := requireCoupleUserIds(c)
|
ids := albumUserIds(c)
|
||||||
if err != nil {
|
var err error
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
|
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -131,10 +137,7 @@ func albumDelete(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ids, err := requireCoupleUserIds(c)
|
ids := albumUserIds(c)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
id := c.In("id")
|
id := c.In("id")
|
||||||
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]); a == nil {
|
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", id, ids[0], ids[1]); a == nil {
|
||||||
return c.Err(404, "相册不存在")
|
return c.Err(404, "相册不存在")
|
||||||
@@ -152,7 +155,7 @@ func setAlbumCover(c *httpapi.Ctx) error {
|
|||||||
if c.Method != "POST" {
|
if c.Method != "POST" {
|
||||||
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
|
return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed)
|
||||||
}
|
}
|
||||||
ids := coupleUserIds(c, "")
|
ids := albumUserIds(c)
|
||||||
albumID := c.Form("album_id")
|
albumID := c.Form("album_id")
|
||||||
var a store.Row
|
var a store.Row
|
||||||
if len(ids) == 2 {
|
if len(ids) == 2 {
|
||||||
@@ -194,7 +197,7 @@ func photos(c *httpapi.Ctx) error {
|
|||||||
if albumID == "" {
|
if albumID == "" {
|
||||||
albumID = "0"
|
albumID = "0"
|
||||||
}
|
}
|
||||||
ids := coupleUserIds(c, "")
|
ids := albumUserIds(c)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return listResponse(c, nil, 0, nil)
|
return listResponse(c, nil, 0, nil)
|
||||||
}
|
}
|
||||||
@@ -221,15 +224,11 @@ func photoUpload(c *httpapi.Ctx) error {
|
|||||||
if uid == "" {
|
if uid == "" {
|
||||||
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
|
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
|
||||||
}
|
}
|
||||||
cid := c.CoupleID()
|
|
||||||
if cid == "" {
|
|
||||||
return c.ErrCode(400, "请先绑定TA", upload.ErrNotBound)
|
|
||||||
}
|
|
||||||
albumID := c.Form("album_id")
|
albumID := c.Form("album_id")
|
||||||
if albumID == "" {
|
if albumID == "" {
|
||||||
albumID = "0"
|
albumID = "0"
|
||||||
}
|
}
|
||||||
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil {
|
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, albumUserIds(c)[0], albumUserIds(c)[1]); a == nil {
|
||||||
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
|
return c.ErrCode(404, "相册不存在", upload.ErrAlbumNotFound)
|
||||||
}
|
}
|
||||||
description := c.Form("description")
|
description := c.Form("description")
|
||||||
@@ -295,7 +294,7 @@ func photoUpload(c *httpapi.Ctx) error {
|
|||||||
return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed)
|
return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed)
|
||||||
}
|
}
|
||||||
if len(uploaded) > 0 {
|
if len(uploaded) > 0 {
|
||||||
actID := logActivity(c.DB, cid, uid, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)), "")
|
actID := albumActivity(c, "photo_upload", fmt.Sprintf("上传了%d张照片", len(uploaded)))
|
||||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||||
"thing1": "相册有更新",
|
"thing1": "相册有更新",
|
||||||
@@ -316,10 +315,8 @@ func photoDelete(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ids, err := requireCoupleUserIds(c)
|
ids := albumUserIds(c)
|
||||||
if err != nil {
|
var err error
|
||||||
return err
|
|
||||||
}
|
|
||||||
id := c.In("id")
|
id := c.In("id")
|
||||||
photo, err := c.DB.QueryOne("SELECT p.path, p.album_id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1])
|
photo, err := c.DB.QueryOne("SELECT p.path, p.album_id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -345,15 +342,11 @@ func photoAdd(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cid, err := requireCoupleID(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !c.InBool("album_id") || !c.InBool("path") {
|
if !c.InBool("album_id") || !c.InBool("path") {
|
||||||
return c.Err(400, "参数不完整")
|
return c.Err(400, "参数不完整")
|
||||||
}
|
}
|
||||||
albumID := c.In("album_id")
|
albumID := c.In("album_id")
|
||||||
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND couple_id = ?", albumID, cid); a == nil {
|
if a, _ := c.DB.QueryOne("SELECT id FROM albums WHERE id = ? AND created_by IN (?, ?)", albumID, albumUserIds(c)[0], albumUserIds(c)[1]); a == nil {
|
||||||
return c.Err(404, "相册不存在")
|
return c.Err(404, "相册不存在")
|
||||||
}
|
}
|
||||||
uid := c.UID()
|
uid := c.UID()
|
||||||
@@ -368,7 +361,7 @@ func photoAdd(c *httpapi.Ctx) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
setFirstCover(c, albumID, c.InStr("path"))
|
setFirstCover(c, albumID, c.InStr("path"))
|
||||||
actID := logActivity(c.DB, cid, uid, "photo_upload", "上传了照片", "")
|
actID := albumActivity(c, "photo_upload", "上传了照片")
|
||||||
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
if partnerID := partnerUserId(c.DB, uid); partnerID != "" {
|
||||||
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{
|
||||||
"thing1": "相册有更新",
|
"thing1": "相册有更新",
|
||||||
@@ -385,10 +378,8 @@ func photoUpdate(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ids, err := requireCoupleUserIds(c)
|
ids := albumUserIds(c)
|
||||||
if err != nil {
|
var err error
|
||||||
return err
|
|
||||||
}
|
|
||||||
id := c.InTrim("id")
|
id := c.InTrim("id")
|
||||||
if id == "" || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
|
if id == "" || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
|
||||||
return c.Err(400, "参数不完整")
|
return c.Err(400, "参数不完整")
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -92,6 +94,13 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
if err := db.InitSchema(); err != nil {
|
if err := db.InitSchema(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Simulate the deployed album schema and verify AutoMigrate relaxes its old binding requirement.
|
||||||
|
if err := db.ORM.Exec(`ALTER TABLE past_album ALTER COLUMN couple_id SET NOT NULL`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.InitSchema(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := db.MigrateSmokingRecords(); err != nil {
|
if err := db.MigrateSmokingRecords(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -99,16 +108,18 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
db.NumericAsString = stringsMode
|
db.NumericAsString = stringsMode
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
cfg := &config.Config{DataDir: dataDir, MaxUploadMB: 5}
|
cfg := &config.Config{DataDir: dataDir, MaxUploadMB: 5}
|
||||||
wx := wechat.New("test-app", "test-secret", "test-template", dataDir, db)
|
cacheServer := miniredis.RunT(t)
|
||||||
|
cacheClient := redis.NewClient(&redis.Options{Addr: cacheServer.Addr()})
|
||||||
|
defer cacheClient.Close()
|
||||||
|
wx := wechat.New("test-app", "test-secret", "test-template", cacheClient, "test:", db)
|
||||||
oldTransport := http.DefaultTransport
|
oldTransport := http.DefaultTransport
|
||||||
http.DefaultTransport = apiTransport(func(r *http.Request) (*http.Response, error) {
|
http.DefaultTransport = apiTransport(func(r *http.Request) (*http.Response, error) {
|
||||||
body := `{"errcode":0,"access_token":"test-access","expires_in":7200,"openid":"wx-test-user","session_key":"test-session"}`
|
body := `{"errcode":0,"access_token":"test-access","expires_in":7200,"openid":"wx-test-user","session_key":"test-session"}`
|
||||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||||
})
|
})
|
||||||
defer func() { http.DefaultTransport = oldTransport }()
|
defer func() { http.DefaultTransport = oldTransport }()
|
||||||
oldLog := slog.Default()
|
undoLog := zap.ReplaceGlobals(zap.NewNop())
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
defer undoLog()
|
||||||
defer slog.SetDefault(oldLog)
|
|
||||||
app := &httpapi.App{Cfg: cfg, DB: db, Wechat: wx, Geo: geo.New(""), Admin: httpapi.NewAdminSessions()}
|
app := &httpapi.App{Cfg: cfg, DB: db, Wechat: wx, Geo: geo.New(""), Admin: httpapi.NewAdminSessions()}
|
||||||
router := httpapi.NewRouter(app, fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}})
|
router := httpapi.NewRouter(app, fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}})
|
||||||
testConfig := os.Getenv("PAST_TEST_CONFIG")
|
testConfig := os.Getenv("PAST_TEST_CONFIG")
|
||||||
@@ -183,9 +194,6 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
}
|
}
|
||||||
var value any
|
var value any
|
||||||
if err := json.Unmarshal(out.Body.Bytes(), &value); err != nil {
|
if err := json.Unmarshal(out.Body.Bytes(), &value); err != nil {
|
||||||
if action == "storage_pay_notify" {
|
|
||||||
return M{"body": out.Body.String()}
|
|
||||||
}
|
|
||||||
t.Fatalf("%s invalid JSON: %s", action, out.Body.String())
|
t.Fatalf("%s invalid JSON: %s", action, out.Body.String())
|
||||||
}
|
}
|
||||||
if result, ok := value.(map[string]any); ok {
|
if result, ok := value.(map[string]any); ok {
|
||||||
@@ -246,6 +254,30 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
post("login", "", M{"phone": "13800000001", "username": "13800000001", "password": "testpass1"})
|
post("login", "", M{"phone": "13800000001", "username": "13800000001", "password": "testpass1"})
|
||||||
get("check_auth", aToken, nil)
|
get("check_auth", aToken, nil)
|
||||||
get("my_info", aToken, nil)
|
get("my_info", aToken, nil)
|
||||||
|
// Personal albums work before binding and remain scoped to the creator.
|
||||||
|
personalAlbum := id(post("album_add", aToken, M{"title": "Personal album"}), "id")
|
||||||
|
personalFile := id(uploadFile("upload", aToken, "file", nil), "file_id")
|
||||||
|
personalPhoto := id(post("photo_add", aToken, M{"album_id": personalAlbum, "file_id": personalFile}), "id")
|
||||||
|
uploadFile("set_album_cover", aToken, "cover", M{"album_id": personalAlbum})
|
||||||
|
uploadFile("photo_upload", aToken, "photos", M{"album_id": personalAlbum})
|
||||||
|
post("photo_update", aToken, M{"id": personalPhoto, "taken_at": today(), "location": "Personal place", "activity": "Personal photo"})
|
||||||
|
post("album_update", aToken, M{"id": personalAlbum, "title": "Personal edited"})
|
||||||
|
if got := get("photos", aToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 2 {
|
||||||
|
t.Fatalf("personal photos missing: %v", got)
|
||||||
|
}
|
||||||
|
if got := get("photos", otherToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 0 {
|
||||||
|
t.Fatalf("personal photos leaked: %v", got)
|
||||||
|
}
|
||||||
|
if got := get("album_list", otherToken, M{"page": 1, "page_size": 20}); store.ToInt(got["total"]) != 0 {
|
||||||
|
t.Fatalf("personal albums leaked: %v", got)
|
||||||
|
}
|
||||||
|
request("photo_add", "POST", otherToken, M{"album_id": personalAlbum, "file_id": personalFile}, 404)
|
||||||
|
request("photo_update", "POST", otherToken, M{"id": personalPhoto, "taken_at": "", "location": "", "activity": ""}, 404)
|
||||||
|
request("album_delete", "POST", otherToken, M{"id": personalAlbum}, 404)
|
||||||
|
var personal model.Album
|
||||||
|
if err := db.ORM.First(&personal, "id=?", personalAlbum).Error; err != nil || personal.CoupleId != nil {
|
||||||
|
t.Fatalf("personal album relation: %+v %v", personal, err)
|
||||||
|
}
|
||||||
link := post("generate_invite_link", aToken, nil)
|
link := post("generate_invite_link", aToken, nil)
|
||||||
_ = link
|
_ = link
|
||||||
post("update_profile", aToken, M{"nickname": "Alpha updated", "gender": "male"})
|
post("update_profile", aToken, M{"nickname": "Alpha updated", "gender": "male"})
|
||||||
@@ -258,6 +290,10 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
t.Fatalf("binding mismatch: %+v %v", user, err)
|
t.Fatalf("binding mismatch: %+v %v", user, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if got := get("photos", bToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 2 {
|
||||||
|
t.Fatalf("partner cannot view pre-binding photos: %v", got)
|
||||||
|
}
|
||||||
|
post("photo_update", bToken, M{"id": personalPhoto, "taken_at": today(), "location": "Shared now", "activity": "Partner edit"})
|
||||||
post("update_couple_info", aToken, M{"love_start_date": "2026-01-01", "love_story": "story"})
|
post("update_couple_info", aToken, M{"love_start_date": "2026-01-01", "love_story": "story"})
|
||||||
diary := post("diary_add", aToken, M{"title": "Diary A", "content": "body", "diary_date": today(), "images": []string{}})
|
diary := post("diary_add", aToken, M{"title": "Diary A", "content": "body", "diary_date": today(), "images": []string{}})
|
||||||
diaryID := id(diary, "id")
|
diaryID := id(diary, "id")
|
||||||
@@ -307,7 +343,7 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
post("pet_equip", aToken, M{"item_key": "collar_basic", "equip": 1})
|
post("pet_equip", aToken, M{"item_key": "collar_basic", "equip": 1})
|
||||||
post("subscribe_template", aToken, M{"template_id": "test-template", "template_type": "notify"})
|
post("subscribe_template", aToken, M{"template_id": "test-template", "template_type": "notify"})
|
||||||
post("unsubscribe_template", aToken, M{"template_type": "notify"})
|
post("unsubscribe_template", aToken, M{"template_type": "notify"})
|
||||||
post("admin_login", "", M{"username": "admin", "password": "admin123"})
|
post("admin_login", "", M{"username": "past", "password": "past"})
|
||||||
uploadFile("upload", aToken, "file", M{"type": "diary"})
|
uploadFile("upload", aToken, "file", M{"type": "diary"})
|
||||||
uploadFile("upload_avatar", aToken, "avatar", nil)
|
uploadFile("upload_avatar", aToken, "avatar", nil)
|
||||||
uploadFile("upload_partner_avatar", aToken, "avatar", M{"partner_id": bID})
|
uploadFile("upload_partner_avatar", aToken, "avatar", M{"partner_id": bID})
|
||||||
@@ -401,7 +437,7 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
post("admin_storage_user_update", "admin", M{"user_id": aID, "quota_mb": 500, "plan_key": "month"})
|
post("admin_storage_user_update", "admin", M{"user_id": aID, "quota_mb": 500, "plan_key": "month"})
|
||||||
post("admin_reset_user_password", "admin", M{"user_id": outsider["user_id"], "new_password": "resetpass"})
|
post("admin_reset_user_password", "admin", M{"user_id": outsider["user_id"], "new_password": "resetpass"})
|
||||||
post("login", "", M{"phone": "13800000003", "password": "resetpass"})
|
post("login", "", M{"phone": "13800000003", "password": "resetpass"})
|
||||||
post("admin_change_password", "admin", M{"old_password": "admin123", "new_password": "admin-test-pass"})
|
post("admin_change_password", "admin", M{"old_password": "past", "new_password": "admin-test-pass"})
|
||||||
post("admin_change_username", "admin", M{"new_username": "integration-admin"})
|
post("admin_change_username", "admin", M{"new_username": "integration-admin"})
|
||||||
for _, term := range []string{store.ToStr(a["display_id"]), aID, "Alpha"} {
|
for _, term := range []string{store.ToStr(a["display_id"]), aID, "Alpha"} {
|
||||||
get("admin_users", "admin", M{"search": term})
|
get("admin_users", "admin", M{"search": term})
|
||||||
@@ -412,48 +448,9 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
if err := db.ORM.First(&persisted, "id=?", aID).Error; err != nil || persisted.Nickname != "Admin edited A" || persisted.StorageQuotaMb != 500 {
|
if err := db.ORM.First(&persisted, "id=?", aID).Error; err != nil || persisted.Nickname != "Admin edited A" || persisted.StorageQuotaMb != 500 {
|
||||||
t.Fatalf("profile/storage write mismatch: %+v %v", persisted, err)
|
t.Fatalf("profile/storage write mismatch: %+v %v", persisted, err)
|
||||||
}
|
}
|
||||||
post("admin_storage_settings", "admin", M{"free_mb": 200, "month_price_cents": 500, "year_price_cents": 2990, "month_product_id": "month-test", "year_product_id": "year-test", "pay_offer_id": "test-offer", "pay_app_key": "test-key", "pay_sandbox_key": "test-sandbox", "pay_env": 1})
|
post("admin_storage_settings", "admin", M{"free_mb": 200, "month_price_cents": 500, "year_price_cents": 2990})
|
||||||
if _, err := db.Exec("UPDATE users SET session_key=? WHERE id=?", "test-session", aID); err != nil {
|
exerciseWechatPayment(t, db, app, engine, request, covered, happy, aID, aToken, otherToken)
|
||||||
t.Fatal(err)
|
exerciseStorageTiers(t, db, app, request, aID, aToken, bToken)
|
||||||
}
|
|
||||||
order := post("storage_create_order", aToken, M{"plan_key": "month"})
|
|
||||||
yearOrder := post("storage_order_create", aToken, M{"plan_key": "year"})
|
|
||||||
// Simultaneous duplicate delivery must grant one month exactly once.
|
|
||||||
var before model.User
|
|
||||||
db.ORM.First(&before, "id=?", aID)
|
|
||||||
errors := make(chan error, 4)
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
go func() { errors <- deliverStorageOrder(db, store.ToStr(order["out_trade_no"]), "wx-order") }()
|
|
||||||
}
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
if err := <-errors; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var after model.User
|
|
||||||
db.ORM.First(&after, "id=?", aID)
|
|
||||||
expectedExpiry := before.StorageVipExpireAt.AddDate(0, 1, 0)
|
|
||||||
if after.StorageVipExpireAt == nil || !after.StorageVipExpireAt.Equal(expectedExpiry) {
|
|
||||||
t.Fatalf("duplicate payment extended membership more than once: before=%v after=%v", before.StorageVipExpireAt, after.StorageVipExpireAt)
|
|
||||||
}
|
|
||||||
// Exercise the actual XML delivery callback twice; verify its persisted effects.
|
|
||||||
notifyXML := "<xml><Event>xpay_goods_deliver_notify</Event><OutTradeNo>" + store.ToStr(yearOrder["out_trade_no"]) + "</OutTradeNo><WeChatPayInfo><MchOrderNo>wx-year-order</MchOrderNo></WeChatPayInfo></xml>"
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
req := httptest.NewRequest("POST", "/api/storage_pay_notify", strings.NewReader(notifyXML))
|
|
||||||
req.Header.Set("Content-Type", "application/xml")
|
|
||||||
out := httptest.NewRecorder()
|
|
||||||
engine.ServeHTTP(out, req)
|
|
||||||
covered["storage_pay_notify"]++
|
|
||||||
if out.Code != 200 || !strings.Contains(out.Body.String(), "<ErrCode>0</ErrCode>") {
|
|
||||||
t.Fatalf("delivery callback: %d %s", out.Code, out.Body.String())
|
|
||||||
}
|
|
||||||
happy["storage_pay_notify"] = true
|
|
||||||
}
|
|
||||||
var yearUser model.User
|
|
||||||
db.ORM.First(&yearUser, "id=?", aID)
|
|
||||||
if yearUser.StorageVipExpireAt == nil || !yearUser.StorageVipExpireAt.Equal(expectedExpiry.AddDate(1, 0, 0)) {
|
|
||||||
t.Fatalf("year callback granted incorrect membership: %v", yearUser.StorageVipExpireAt)
|
|
||||||
}
|
|
||||||
post("wechat_login", "", M{"code": "mock-wechat-code"})
|
post("wechat_login", "", M{"code": "mock-wechat-code"})
|
||||||
// Three separate couples verify all binding entry points without reusing owners.
|
// Three separate couples verify all binding entry points without reusing owners.
|
||||||
d := post("register", "", M{"phone": "13800000004", "password": "testpass4", "nickname": "D"})
|
d := post("register", "", M{"phone": "13800000004", "password": "testpass4", "nickname": "D"})
|
||||||
@@ -565,7 +562,7 @@ func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Every private route must reject anonymous access before touching user data.
|
// Every private route must reject anonymous access before touching user data.
|
||||||
public := map[string]bool{"register": true, "login": true, "wechat_login": true, "site_info": true, "site_stats": true, "admin_login": true, "check_invite_code": true, "register_by_invite": true, "check_auth": true, "storage_pay_notify": true}
|
public := map[string]bool{"register": true, "login": true, "wechat_login": true, "site_info": true, "site_stats": true, "admin_login": true, "check_invite_code": true, "register_by_invite": true, "check_auth": true, "wechat_pay_notify": true}
|
||||||
for _, action := range router.Actions() {
|
for _, action := range router.Actions() {
|
||||||
if !public[action] {
|
if !public[action] {
|
||||||
request(action, "GET", "", nil, 401)
|
request(action, "GET", "", nil, 401)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"past/internal/store"
|
"past/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://past.sundynix.cn/backend/'),MEDIA_BASE 未定义
|
// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://we.sundynix.cn/backend/'),MEDIA_BASE 未定义
|
||||||
const mediaBase = "https://past.sundynix.cn/backend/"
|
const mediaBase = "https://we.sundynix.cn/backend/"
|
||||||
|
|
||||||
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
var tagRe = regexp.MustCompile(`<[^>]*>`)
|
||||||
|
|
||||||
|
|||||||
+170
-374
@@ -1,39 +1,29 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"github.com/google/uuid"
|
||||||
"encoding/xml"
|
"go.uber.org/zap"
|
||||||
"fmt"
|
"gorm.io/gorm"
|
||||||
"log/slog"
|
"gorm.io/gorm/clause"
|
||||||
|
"past/internal/model"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"past/internal/config"
|
|
||||||
"past/internal/httpapi"
|
"past/internal/httpapi"
|
||||||
"past/internal/store"
|
"past/internal/store"
|
||||||
"past/internal/virtualpay"
|
|
||||||
"past/internal/wechat"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
settingFreeMB = "storage_free_mb"
|
settingFreeMB = "storage_free_mb"
|
||||||
settingMonthPrice = "storage_plan_month_price"
|
settingMonthPrice = "storage_plan_month_price"
|
||||||
settingMonthProduct = "storage_plan_month_product"
|
|
||||||
settingYearPrice = "storage_plan_year_price"
|
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 (
|
const (
|
||||||
defaultFreeMB int64 = 200
|
defaultFreeMB int64 = 200
|
||||||
defaultMonthPrice int64 = 500
|
defaultMonthPrice int64 = 500
|
||||||
defaultYearPrice int64 = 2990
|
defaultYearPrice int64 = 2990
|
||||||
defaultPayMode string = "short_series_goods"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrStorageQuotaExceeded 上传时存储超额的错误码,前端据此引导付费。
|
// ErrStorageQuotaExceeded 上传时存储超额的错误码,前端据此引导付费。
|
||||||
@@ -41,9 +31,10 @@ const ErrStorageQuotaExceeded = 10001
|
|||||||
|
|
||||||
func registerStorage(r *httpapi.Router) {
|
func registerStorage(r *httpapi.Router) {
|
||||||
r.Handle("storage_info", storageInfo)
|
r.Handle("storage_info", storageInfo)
|
||||||
|
r.Handle("wechat_pay_notify", wechatPayNotify)
|
||||||
|
r.Handle("storage_order_status", storageOrderStatus)
|
||||||
r.Handle("storage_create_order", storageOrderCreate)
|
r.Handle("storage_create_order", storageOrderCreate)
|
||||||
r.Handle("storage_order_create", storageOrderCreate)
|
r.Handle("storage_order_create", storageOrderCreate)
|
||||||
r.Handle("storage_pay_notify", storagePayNotify)
|
|
||||||
r.Handle("admin_storage_settings", adminStorageSettings)
|
r.Handle("admin_storage_settings", adminStorageSettings)
|
||||||
r.Handle("admin_storage_users", adminStorageUsers)
|
r.Handle("admin_storage_users", adminStorageUsers)
|
||||||
r.Handle("admin_storage_user_update", adminStorageUserUpdate)
|
r.Handle("admin_storage_user_update", adminStorageUserUpdate)
|
||||||
@@ -58,33 +49,6 @@ func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 {
|
|||||||
return row.Int("value")
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count deduplicated MinIO objects, not directories on the API server.
|
// Count deduplicated MinIO objects, not directories on the API server.
|
||||||
func allStorageUsedBytes() int64 {
|
func allStorageUsedBytes() int64 {
|
||||||
if up == nil || up.DB == nil {
|
if up == nil || up.DB == nil {
|
||||||
@@ -92,7 +56,7 @@ func allStorageUsedBytes() int64 {
|
|||||||
}
|
}
|
||||||
size, err := store.UsedFileBytes(up.DB.ORM, nil)
|
size, err := store.UsedFileBytes(up.DB.ORM, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("读取存储用量失败", "err", err)
|
zap.S().Errorw("读取存储用量失败", "err", err)
|
||||||
}
|
}
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
@@ -103,7 +67,7 @@ func storageUsedBytes(c *httpapi.Ctx, uid, coupleId string) int64 {
|
|||||||
}
|
}
|
||||||
size, err := store.UsedFileBytes(c.DB.ORM, owners)
|
size, err := store.UsedFileBytes(c.DB.ORM, owners)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("读取用户存储用量失败", "err", err)
|
zap.S().Errorw("读取用户存储用量失败", "err", err)
|
||||||
}
|
}
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
@@ -117,24 +81,15 @@ func storageVipUserIDs(c *httpapi.Ctx, uid string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isStorageVip(c *httpapi.Ctx, uid string) bool {
|
func isStorageVip(c *httpapi.Ctx, uid string) bool {
|
||||||
for _, id := range storageVipUserIDs(c, uid) {
|
a, err := store.StorageSpace(c.DB.ORM, uid)
|
||||||
row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id)
|
return err == nil && a.IsVip
|
||||||
if row != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func storageVipExpireAt(c *httpapi.Ctx, uid string) string {
|
func storageVipExpireAt(c *httpapi.Ctx, uid string) string {
|
||||||
var latest string
|
a, err := store.StorageSpace(c.DB.ORM, uid)
|
||||||
for _, id := range storageVipUserIDs(c, uid) {
|
if err == nil && a.ExpiresAt != nil {
|
||||||
row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id)
|
return a.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||||
if row != nil && row.Str("storage_vip_expire_at") > latest {
|
|
||||||
latest = row.Str("storage_vip_expire_at")
|
|
||||||
}
|
}
|
||||||
}
|
return ""
|
||||||
return latest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageQuotaMB 返回用户实际可用的免费存储容量(MB):
|
// storageQuotaMB 返回用户实际可用的免费存储容量(MB):
|
||||||
@@ -155,21 +110,29 @@ func storageInfo(c *httpapi.Ctx) error {
|
|||||||
}
|
}
|
||||||
coupleID := c.CoupleID()
|
coupleID := c.CoupleID()
|
||||||
freeMB := storageQuotaMB(c, uid)
|
freeMB := storageQuotaMB(c, uid)
|
||||||
vipExpire := storageVipExpireAt(c, uid)
|
plans, err := storageCatalog(c.DB.ORM, true)
|
||||||
plans := []M{
|
if err != nil {
|
||||||
{"key": "month", "name": "包月无限空间", "price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice), "product_id": storageSettingStr(c, settingMonthProduct, "")},
|
return err
|
||||||
{"key": "year", "name": "包年无限空间", "price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), "product_id": storageSettingStr(c, settingYearProduct, "")},
|
}
|
||||||
|
allowance, err := store.StorageSpace(c.DB.ORM, uid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
resp := M{
|
resp := M{
|
||||||
|
"pay_provider": "wechat",
|
||||||
"used_bytes": storageUsedBytes(c, uid, coupleID),
|
"used_bytes": storageUsedBytes(c, uid, coupleID),
|
||||||
"free_mb": freeMB,
|
"free_mb": freeMB,
|
||||||
"free_bytes": freeMB * 1024 * 1024,
|
"free_bytes": freeMB * 1024 * 1024,
|
||||||
"is_vip": isStorageVip(c, uid),
|
"is_vip": allowance.IsVip,
|
||||||
|
"is_unlimited": allowance.Unlimited,
|
||||||
|
"quota_mb": allowance.QuotaMb,
|
||||||
|
"plan_name": allowance.PlanName,
|
||||||
|
"quota_bytes": allowance.QuotaMb * 1024 * 1024,
|
||||||
"vip_expire_at": nil,
|
"vip_expire_at": nil,
|
||||||
"plans": plans,
|
"plans": plans,
|
||||||
}
|
}
|
||||||
if vipExpire != "" {
|
if allowance.ExpiresAt != nil {
|
||||||
resp["vip_expire_at"] = vipExpire
|
resp["vip_expire_at"] = allowance.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
return c.OK(resp)
|
return c.OK(resp)
|
||||||
}
|
}
|
||||||
@@ -178,275 +141,79 @@ func storageOrderCreate(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
uid := c.UID()
|
if c.UID() == "" {
|
||||||
if uid == "" {
|
|
||||||
return c.Err(401, "请先登录")
|
return c.Err(401, "请先登录")
|
||||||
}
|
}
|
||||||
planKey := c.InTrim("plan_key")
|
if !c.App.Cfg.WechatPay.Enabled {
|
||||||
var price int64
|
return c.Err(503, "微信支付服务未启用")
|
||||||
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 == "" {
|
var plan model.StoragePlan
|
||||||
return c.Err(400, "该套餐暂未开放,请稍后再试")
|
r := c.DB.ORM.Where("key=? AND enabled=?", c.InTrim("plan_key"), true).Find(&plan)
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
}
|
}
|
||||||
user, _ := c.DB.QueryOne("SELECT session_key FROM users WHERE id = ?", uid)
|
if r.RowsAffected == 0 {
|
||||||
if user == nil || user.Str("session_key") == "" {
|
return c.Err(400, "套餐已下架或不存在,请刷新")
|
||||||
return c.Err(400, "支付凭证已过期,请重新登录后重试")
|
|
||||||
}
|
}
|
||||||
vp := vpayClient(c)
|
if c.InHas("expected_price_cents") && c.InInt("expected_price_cents") != plan.PriceCents {
|
||||||
if vp.OfferID == "" || vp.AppKey == "" {
|
return c.Err(409, "套餐价格已变更,请刷新后重新购买")
|
||||||
return c.Err(500, "支付服务未配置,请联系管理员")
|
|
||||||
}
|
}
|
||||||
// outTradeNo 要求 8-32 位、不能以下划线开头;NewToken 为 32 位 hex,取前 31 位 + 前缀 S 共 32 位。
|
if value := c.InTrim("expected_plan_version"); value != "" {
|
||||||
outTradeNo := "S" + httpapi.NewToken()[:31]
|
v, err := time.Parse(time.RFC3339Nano, value)
|
||||||
attach, _ := json.Marshal(M{"uid": uid, "plan": planKey})
|
if err != nil || !v.Equal(plan.UpdatedAt) {
|
||||||
signData, err := vp.BuildSignData(productID, int(price), outTradeNo, string(attach))
|
return c.Err(409, "套餐配置已变更,请刷新后重新购买")
|
||||||
if err != nil {
|
|
||||||
return c.Err(500, "生成支付参数失败")
|
|
||||||
}
|
}
|
||||||
var cid any
|
|
||||||
if cidv := c.CoupleID(); cidv != "" {
|
|
||||||
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', ?)",
|
return storageWechatOrderCreate(c, plan)
|
||||||
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 {
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
order, err := tx.QueryOne("SELECT id,user_id,plan_key,status FROM storage_orders WHERE out_trade_no=? FOR UPDATE", outTradeNo)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if order == nil {
|
|
||||||
return fmt.Errorf("订单不存在: %s", outTradeNo)
|
|
||||||
}
|
|
||||||
if order.Str("status") == "success" {
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
months := 1
|
|
||||||
if order.Str("plan_key") == "year" {
|
|
||||||
months = 12
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec("UPDATE users SET storage_vip_expire_at=(COALESCE(storage_vip_expire_at,NOW()) + (? * INTERVAL '1 month')) WHERE id=?", months, order.Str("user_id")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec("UPDATE storage_orders SET status='success',wx_order_id=?,paid_at=NOW() WHERE id=?", wxOrderID, order.Str("id")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 > (NOW() - INTERVAL '24 hour') ORDER BY created_at DESC, 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.Str("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 {
|
func adminStorageSettings(c *httpapi.Ctx) error {
|
||||||
if c.Method == "GET" {
|
if c.Method == "GET" {
|
||||||
return c.OK(M{
|
var plans []model.StoragePlan
|
||||||
"free_mb": storageSettingInt(c, settingFreeMB, defaultFreeMB),
|
var revision, free int64
|
||||||
"month_price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice),
|
err := c.DB.ORM.Transaction(func(tx *gorm.DB) error {
|
||||||
"month_product_id": storageSettingStr(c, settingMonthProduct, ""),
|
if e := tx.Exec("SELECT pg_advisory_xact_lock(520002)").Error; e != nil {
|
||||||
"year_price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice),
|
return e
|
||||||
"year_product_id": storageSettingStr(c, settingYearProduct, ""),
|
}
|
||||||
"pay_mode": storageSettingStr(c, settingPayMode, defaultPayMode),
|
var e error
|
||||||
"pay_env": int(storageSettingInt(c, settingPayEnv, int64(c.App.Cfg.VirtualPay.Env))),
|
plans, e = storageCatalog(tx, false)
|
||||||
"pay_offer_id": storagePaySetting(c, settingPayOfferID, c.App.Cfg.VirtualPay.OfferID),
|
if e != nil {
|
||||||
"pay_app_key": storagePaySetting(c, settingPayAppKey, c.App.Cfg.VirtualPay.AppKey),
|
return e
|
||||||
"pay_sandbox_key": storagePaySetting(c, settingPaySandboxKey, c.App.Cfg.VirtualPay.SandboxKey),
|
}
|
||||||
|
revision, e = catalogVersion(tx)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
free = defaultFreeMB
|
||||||
|
var setting model.Setting
|
||||||
|
r := tx.Where("key=?", settingFreeMB).Find(&setting)
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
if r.RowsAffected > 0 {
|
||||||
|
free = store.ToInt(setting.Value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
month, year := int64(0), int64(0)
|
||||||
|
for _, p := range plans {
|
||||||
|
if p.Key == "month" {
|
||||||
|
month = p.PriceCents
|
||||||
|
}
|
||||||
|
if p.Key == "year" {
|
||||||
|
year = p.PriceCents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.OK(M{"pay_provider": "wechat", "wechat_pay_ready": c.App.WechatPay != nil, "wechat_pay_mch_id": c.App.Cfg.WechatPay.MchID, "free_mb": free, "plans": plans, "revision": revision, "month_price_cents": month, "year_price_cents": year})
|
||||||
}
|
}
|
||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pairs := []struct {
|
return saveStorageCatalog(c)
|
||||||
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 套餐中文名(后台展示用)
|
// storagePlanLabel 套餐中文名(后台展示用)
|
||||||
@@ -472,7 +239,7 @@ func adminStorageUsers(c *httpapi.Ctx) error {
|
|||||||
if !c.QHas("sort") {
|
if !c.QHas("sort") {
|
||||||
sortKey = "used_desc"
|
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"
|
sql := "SELECT id, user_id, username, nickname, avatar, gender, couple_id, created_at, last_login_at, storage_quota_mb, storage_plan, storage_plan_name, storage_plan_started_at, storage_vip_expire_at FROM users"
|
||||||
var where []string
|
var where []string
|
||||||
var params []any
|
var params []any
|
||||||
if vip == "vip" {
|
if vip == "vip" {
|
||||||
@@ -512,7 +279,22 @@ func adminStorageUsers(c *httpapi.Ctx) error {
|
|||||||
quota = storageSettingInt(c, settingFreeMB, defaultFreeMB)
|
quota = storageSettingInt(c, settingFreeMB, defaultFreeMB)
|
||||||
}
|
}
|
||||||
u["free_mb"] = quota
|
u["free_mb"] = quota
|
||||||
|
u["plan_label"] = u.Str("storage_plan_name")
|
||||||
|
if u.Str("plan_label") == "" {
|
||||||
u["plan_label"] = storagePlanLabel(u.Str("storage_plan"))
|
u["plan_label"] = storagePlanLabel(u.Str("storage_plan"))
|
||||||
|
}
|
||||||
|
allowance, e := store.StorageSpace(c.DB.ORM, uid)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
u["quota_mb"], u["is_unlimited"] = allowance.QuotaMb, allowance.Unlimited
|
||||||
|
u["storage_vip"] = allowance.IsVip
|
||||||
|
if allowance.PlanName != "" {
|
||||||
|
u["plan_label"] = allowance.PlanName
|
||||||
|
}
|
||||||
|
if allowance.ExpiresAt != nil {
|
||||||
|
u["storage_vip_expire_at"] = allowance.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
out = append(out, u)
|
out = append(out, u)
|
||||||
}
|
}
|
||||||
switch sortKey {
|
switch sortKey {
|
||||||
@@ -541,73 +323,81 @@ func adminStorageUserUpdate(c *httpapi.Ctx) error {
|
|||||||
if err := c.RequirePOST(); err != nil {
|
if err := c.RequirePOST(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userID := c.InTrim("user_id")
|
uid := c.InTrim("user_id")
|
||||||
if userID == "" {
|
if !store.ValidUUID(uid) {
|
||||||
return c.Err(400, "用户ID无效")
|
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")
|
quota := c.InInt("quota_mb")
|
||||||
if quota < 0 {
|
if quota < 0 || quota > 104857600 {
|
||||||
quota = 0
|
return c.Err(400, "容量无效")
|
||||||
}
|
}
|
||||||
if _, err := c.DB.Exec("UPDATE users SET storage_quota_mb = ? WHERE id = ?", quota, userID); err != nil {
|
key := c.InTrim("plan_key")
|
||||||
|
var plan model.StoragePlan
|
||||||
|
if c.InHas("plan_key") && key != "free" {
|
||||||
|
r := c.DB.ORM.Where("key=?", key).Find(&plan)
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
if r.RowsAffected == 0 {
|
||||||
|
return c.Err(400, "套餐不存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var expiry *time.Time
|
||||||
|
if value := c.InTrim("vip_expire_at"); value != "" {
|
||||||
|
parsed, e := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||||
|
if e != nil {
|
||||||
|
return c.Err(400, "到期时间格式无效")
|
||||||
|
}
|
||||||
|
expiry = &parsed
|
||||||
|
}
|
||||||
|
err := c.DB.ORM.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var user model.User
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id=?", uid).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.InHas("quota_mb") {
|
||||||
|
if err := tx.Model(&user).Update("storage_quota_mb", quota).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2) 套餐 / 到期时间
|
|
||||||
if c.InHas("plan_key") {
|
if c.InHas("plan_key") {
|
||||||
planKey := c.InTrim("plan_key")
|
if err := tx.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil {
|
||||||
switch planKey {
|
return err
|
||||||
case "month", "year":
|
|
||||||
months := 1
|
|
||||||
if planKey == "year" {
|
|
||||||
months = 12
|
|
||||||
}
|
}
|
||||||
expire := c.InTrim("vip_expire_at")
|
if key == "free" {
|
||||||
if expire == "" {
|
return tx.Model(&user).Updates(map[string]any{"storage_plan": "", "storage_plan_name": "", "storage_plan_started_at": nil, "storage_vip_expire_at": nil}).Error
|
||||||
if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = (COALESCE(storage_vip_expire_at, NOW()) + (? * INTERVAL '1 month')) WHERE id = ?", planKey, months, userID); err != nil {
|
}
|
||||||
|
start := time.Now()
|
||||||
|
end := storageExpiry(start, plan.Months)
|
||||||
|
if expiry != nil {
|
||||||
|
end = *expiry
|
||||||
|
}
|
||||||
|
ent := model.StorageEntitlement{UserId: uid, SourceKey: "admin:" + uuid.NewString(), PlanKey: key, PlanName: plan.Name, QuotaMb: plan.QuotaMb, StartsAt: start, ExpiresAt: end}
|
||||||
|
if err := tx.Create(&ent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&user).Updates(map[string]any{"storage_plan": key, "storage_plan_name": plan.Name, "storage_plan_started_at": start, "storage_vip_expire_at": end}).Error
|
||||||
|
}
|
||||||
|
if c.InHas("vip_expire_at") {
|
||||||
|
if expiry == nil {
|
||||||
|
if err := tx.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if err := tx.Model(&model.StorageEntitlement{}).Where("user_id=? AND starts_at<=NOW() AND expires_at>NOW()", uid).Update("expires_at", *expiry).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "free":
|
return tx.Model(&user).Update("storage_vip_expire_at", expiry).Error
|
||||||
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 nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
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})
|
return c.OK(M{"success": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// envLabel 支付环境中文名。
|
|
||||||
func envLabel(env int64) string {
|
|
||||||
if env == 0 {
|
|
||||||
return "正式"
|
|
||||||
}
|
|
||||||
return "沙箱"
|
|
||||||
}
|
|
||||||
|
|
||||||
// adminStorageOrders 存储支付记录查询:支持按订单号/用户ID/昵称搜索、按状态筛选、分页。
|
// adminStorageOrders 存储支付记录查询:支持按订单号/用户ID/昵称搜索、按状态筛选、分页。
|
||||||
func adminStorageOrders(c *httpapi.Ctx) error {
|
func adminStorageOrders(c *httpapi.Ctx) error {
|
||||||
search := strings.TrimSpace(c.Q("search"))
|
search := strings.TrimSpace(c.Q("search"))
|
||||||
@@ -622,17 +412,21 @@ func adminStorageOrders(c *httpapi.Ctx) error {
|
|||||||
params = append(params, status)
|
params = append(params, status)
|
||||||
}
|
}
|
||||||
if search != "" {
|
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 + "%"
|
like := "%" + search + "%"
|
||||||
where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)")
|
if store.ValidUUID(search) {
|
||||||
params = append(params, like, like)
|
where = append(where, "(o.out_trade_no = ? OR o.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
|
||||||
|
params = append(params, search, search, like, like)
|
||||||
|
} else if isNumeric(search) {
|
||||||
|
where = append(where, "(o.out_trade_no = ? OR u.user_id = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
|
||||||
|
params = append(params, search, store.ToInt(search), like, like)
|
||||||
|
} else {
|
||||||
|
where = append(where, "(o.out_trade_no = ? OR u.nickname LIKE ? OR u.username LIKE ?)")
|
||||||
|
params = append(params, search, like, like)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sql := `SELECT o.id, o.user_id AS uid, u.user_id AS display_id, u.nickname, u.username,
|
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
|
o.out_trade_no, o.plan_key, o.plan_name, o.plan_quota_mb, o.plan_months, o.amount_cents, o.pay_provider, 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`
|
FROM storage_orders o LEFT JOIN users u ON u.id = o.user_id`
|
||||||
if len(where) > 0 {
|
if len(where) > 0 {
|
||||||
sql += " WHERE " + strings.Join(where, " AND ")
|
sql += " WHERE " + strings.Join(where, " AND ")
|
||||||
@@ -643,8 +437,10 @@ func adminStorageOrders(c *httpapi.Ctx) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, o := range rs {
|
for _, o := range rs {
|
||||||
|
o["plan_label"] = o.Str("plan_name")
|
||||||
|
if o.Str("plan_label") == "" {
|
||||||
o["plan_label"] = storagePlanLabel(o.Str("plan_key"))
|
o["plan_label"] = storagePlanLabel(o.Str("plan_key"))
|
||||||
o["env_label"] = envLabel(o.Int("env"))
|
}
|
||||||
}
|
}
|
||||||
return c.OK(paged(c, rs))
|
return c.OK(paged(c, rs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Match PostgreSQL interval semantics: Jan 31 + one month ends in February.
|
||||||
|
func storageExpiry(start time.Time, months int64) time.Time {
|
||||||
|
first := time.Date(start.Year(), start.Month()+time.Month(months), 1, start.Hour(), start.Minute(), start.Second(), start.Nanosecond(), start.Location())
|
||||||
|
last := time.Date(first.Year(), first.Month()+1, 0, 0, 0, 0, 0, start.Location()).Day()
|
||||||
|
return time.Date(first.Year(), first.Month(), min(start.Day(), last), start.Hour(), start.Minute(), start.Second(), start.Nanosecond(), start.Location())
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStorageExpiryClampsMonthEnd(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
start string
|
||||||
|
months int64
|
||||||
|
want string
|
||||||
|
}{{"2026-01-31", 1, "2026-02-28"}, {"2024-01-31", 1, "2024-02-29"}, {"2024-02-29", 12, "2025-02-28"}} {
|
||||||
|
start, _ := time.Parse(time.DateOnly, tc.start)
|
||||||
|
if got := storageExpiry(start, tc.months).Format(time.DateOnly); got != tc.want {
|
||||||
|
t.Fatalf("%s + %d months = %s, want %s", tc.start, tc.months, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"past/internal/httpapi"
|
||||||
|
"past/internal/model"
|
||||||
|
"past/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func storageCatalog(db *gorm.DB, enabledOnly bool) ([]model.StoragePlan, error) {
|
||||||
|
plans := []model.StoragePlan{}
|
||||||
|
if enabledOnly {
|
||||||
|
db = db.Where("enabled=?", true)
|
||||||
|
}
|
||||||
|
err := db.Order("sort_order ASC, created_at ASC, id ASC").Find(&plans).Error
|
||||||
|
return plans, err
|
||||||
|
}
|
||||||
|
func catalogVersion(db *gorm.DB) (int64, error) {
|
||||||
|
var s model.Setting
|
||||||
|
r := db.Where("key=?", "storage_plans_revision").Find(&s)
|
||||||
|
return store.ToInt(s.Value), r.Error
|
||||||
|
}
|
||||||
|
func writeStorageSetting(tx *gorm.DB, key string, value int64) error {
|
||||||
|
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}}, DoUpdates: clause.Assignments(map[string]any{"value": fmt.Sprint(value)})}).Create(&model.Setting{Key: key, Value: fmt.Sprint(value)}).Error
|
||||||
|
}
|
||||||
|
func validateStoragePlans(plans []model.StoragePlan) error {
|
||||||
|
if len(plans) > 100 {
|
||||||
|
return fmt.Errorf("套餐最多100档")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := range plans {
|
||||||
|
p := &plans[i]
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
if p.Key == "" {
|
||||||
|
p.Key = uuid.NewString()
|
||||||
|
}
|
||||||
|
if p.Key != "month" && p.Key != "year" && !store.ValidUUID(p.Key) {
|
||||||
|
return fmt.Errorf("无效的套餐标识")
|
||||||
|
}
|
||||||
|
if seen[p.Key] {
|
||||||
|
return fmt.Errorf("套餐标识重复")
|
||||||
|
}
|
||||||
|
seen[p.Key] = true
|
||||||
|
if p.Name == "" || utf8.RuneCountInString(p.Name) > 60 || p.Months < 1 || p.Months > 120 || p.QuotaMb < 0 || p.QuotaMb > 104857600 || p.PriceCents < 1 || p.PriceCents > 100000000 || p.SortOrder < 0 || p.SortOrder > 1000000 {
|
||||||
|
return fmt.Errorf("套餐名称、容量、有效月数、价格或排序无效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errCatalogConflict = errors.New("套餐配置已被其他管理员修改,请刷新后重试")
|
||||||
|
|
||||||
|
func saveStorageCatalog(c *httpapi.Ctx) error {
|
||||||
|
free := c.InInt("free_mb")
|
||||||
|
if free < 0 || free > 104857600 {
|
||||||
|
return c.Err(400, "免费容量无效")
|
||||||
|
}
|
||||||
|
var plans []model.StoragePlan
|
||||||
|
modern := c.InHas("plans")
|
||||||
|
if modern {
|
||||||
|
raw, err := json.Marshal(c.Input()["plans"])
|
||||||
|
if err != nil {
|
||||||
|
return c.Err(400, "套餐列表格式无效")
|
||||||
|
}
|
||||||
|
// Decode only editable fields, never client-supplied BaseModel IDs/timestamps.
|
||||||
|
var input []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
QuotaMb int64 `json:"quota_mb"`
|
||||||
|
Months int64 `json:"months"`
|
||||||
|
PriceCents int64 `json:"price_cents"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int64 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
if string(raw) == "null" || json.Unmarshal(raw, &input) != nil {
|
||||||
|
return c.Err(400, "套餐列表格式无效")
|
||||||
|
}
|
||||||
|
for _, p := range input {
|
||||||
|
plans = append(plans, model.StoragePlan{Key: p.Key, Name: p.Name, QuotaMb: p.QuotaMb, Months: p.Months, PriceCents: p.PriceCents, Enabled: p.Enabled, SortOrder: p.SortOrder})
|
||||||
|
}
|
||||||
|
if err := validateStoragePlans(plans); err != nil {
|
||||||
|
return c.Err(400, err.Error())
|
||||||
|
}
|
||||||
|
if !c.InHas("revision") {
|
||||||
|
return c.Err(400, "缺少套餐配置版本,请刷新")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := c.DB.ORM.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("SELECT pg_advisory_xact_lock(520002)").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version, err := catalogVersion(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if modern && version != c.InInt("revision") {
|
||||||
|
return errCatalogConflict
|
||||||
|
}
|
||||||
|
if modern {
|
||||||
|
keys := []string{}
|
||||||
|
for _, p := range plans {
|
||||||
|
var existing model.StoragePlan
|
||||||
|
r := tx.Unscoped().Where("key=?", p.Key).Find(&existing)
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
if r.RowsAffected == 0 {
|
||||||
|
if err := tx.Create(&p).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if existing.DeletedAt.Valid {
|
||||||
|
return fmt.Errorf("已删除套餐不可重新使用原标识")
|
||||||
|
}
|
||||||
|
if err := tx.Model(&existing).Updates(map[string]any{"name": p.Name, "quota_mb": p.QuotaMb, "months": p.Months, "price_cents": p.PriceCents, "enabled": p.Enabled, "sort_order": p.SortOrder}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys = append(keys, p.Key)
|
||||||
|
}
|
||||||
|
q := tx.Where("1=1")
|
||||||
|
if len(keys) > 0 {
|
||||||
|
q = q.Where("key NOT IN ?", keys)
|
||||||
|
}
|
||||||
|
if err := q.Delete(&model.StoragePlan{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Older admin clients can still update the two original plan prices.
|
||||||
|
for key, field := range map[string]string{"month": "month_price_cents", "year": "year_price_cents"} {
|
||||||
|
if !c.InHas(field) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
price := c.InInt(field)
|
||||||
|
if price <= 0 || price > 100000000 {
|
||||||
|
return fmt.Errorf("套餐价格无效")
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.StoragePlan{}).Where("key=?", key).Update("price_cents", price).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeStorageSetting(tx, settingFreeMB, free); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeStorageSetting(tx, "storage_plans_revision", version+1)
|
||||||
|
})
|
||||||
|
if errors.Is(err, errCatalogConflict) {
|
||||||
|
return c.Err(409, err.Error())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return c.Err(400, err.Error())
|
||||||
|
}
|
||||||
|
return c.OK(M{"success": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||||
|
"past/internal/config"
|
||||||
|
"past/internal/httpapi"
|
||||||
|
"past/internal/model"
|
||||||
|
"past/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func exerciseStorageTiers(t *testing.T, db *store.DB, app *httpapi.App, request func(string, string, string, M, int) M, uid, token, partnerToken string) {
|
||||||
|
t.Helper()
|
||||||
|
original := request("admin_storage_settings", "GET", "admin", nil, 200)
|
||||||
|
revision := store.ToInt(original["revision"])
|
||||||
|
small, large, hidden := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||||
|
plans := []M{
|
||||||
|
{"key": small, "name": "1GB 测试", "quota_mb": 1024, "months": 1, "price_cents": 1, "enabled": true, "sort_order": 2},
|
||||||
|
{"key": large, "name": "5GB 季度", "quota_mb": 5120, "months": 3, "price_cents": 1800, "enabled": true, "sort_order": 1},
|
||||||
|
{"key": hidden, "name": "下架套餐", "quota_mb": 10240, "months": 12, "price_cents": 10000, "enabled": false, "sort_order": 0},
|
||||||
|
}
|
||||||
|
request("admin_storage_settings", "POST", token, M{"free_mb": 200, "plans": plans, "revision": revision}, 401)
|
||||||
|
invalid := append([]M{}, plans...)
|
||||||
|
invalid[0] = M{"key": small, "name": "bad", "quota_mb": -1, "months": 1, "price_cents": 1, "enabled": true}
|
||||||
|
request("admin_storage_settings", "POST", "admin", M{"free_mb": 200, "plans": invalid, "revision": revision}, 400)
|
||||||
|
request("admin_storage_settings", "POST", "admin", M{"free_mb": 200, "plans": plans, "revision": revision}, 200)
|
||||||
|
request("admin_storage_settings", "POST", "admin", M{"free_mb": 100, "plans": plans, "revision": revision}, 409)
|
||||||
|
info := request("storage_info", "GET", token, nil, 200)
|
||||||
|
visible := info["plans"].([]any)
|
||||||
|
if len(visible) != 2 || visible[0].(M)["key"] != large {
|
||||||
|
t.Fatalf("hidden/sort rules wrong: %+v", visible)
|
||||||
|
}
|
||||||
|
oldCfg, oldGateway := app.Cfg.WechatPay, app.WechatPay
|
||||||
|
defer func() { app.Cfg.WechatPay = oldCfg; app.WechatPay = oldGateway }()
|
||||||
|
app.Cfg.WechatPay = config.WechatPayConfig{Enabled: true, AppID: "tier-app", MchID: "tier-merchant"}
|
||||||
|
fake := &paymentFake{}
|
||||||
|
app.WechatPay = fake
|
||||||
|
request("storage_create_order", "POST", token, M{"plan_key": large, "expected_price_cents": 1}, 409)
|
||||||
|
request("storage_create_order", "POST", token, M{"plan_key": hidden}, 400)
|
||||||
|
request("storage_create_order", "POST", token, M{"plan_key": "does-not-exist"}, 400)
|
||||||
|
// Remove prior test memberships so finite quotas can be checked independently.
|
||||||
|
owners, err := store.UploadOwners(db.ORM, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ORM.Where("user_id IN ?", owners).Delete(&model.StorageEntitlement{}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ORM.Model(&model.User{}).Where("id IN ?", owners).Updates(map[string]any{"storage_vip_expire_at": nil, "storage_plan": "", "storage_plan_name": "", "storage_quota_mb": 0}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
create := func(key string) model.StorageOrder {
|
||||||
|
r := request("storage_create_order", "POST", token, M{"plan_key": key, "amount_cents": 99999999, "quota_mb": 99999999}, 200)
|
||||||
|
var o model.StorageOrder
|
||||||
|
if e := db.ORM.Where("out_trade_no=?", r["out_trade_no"]).First(&o).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
deliver := func(o model.StorageOrder) {
|
||||||
|
tx := &payments.Transaction{Appid: core.String(o.PayAppId), Mchid: core.String(o.PayMchId), OutTradeNo: core.String(o.OutTradeNo), TransactionId: core.String("tiers-" + o.OutTradeNo), TradeState: core.String("SUCCESS"), TradeType: core.String("JSAPI"), SuccessTime: core.String(time.Now().Format(time.RFC3339)), Amount: &payments.TransactionAmount{Total: core.Int64(o.AmountCents), Currency: core.String("CNY")}, Payer: &payments.TransactionPayer{Openid: core.String(o.PayerOpenId)}}
|
||||||
|
if err := deliverWechatStorageOrder(db, tx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := deliverWechatStorageOrder(db, tx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order := create(small)
|
||||||
|
if order.AmountCents != 1 || order.PlanQuotaMb != 1024 || order.PlanMonths != 1 || order.PlanName != "1GB 测试" {
|
||||||
|
t.Fatalf("wrong snapshot: %+v", order)
|
||||||
|
}
|
||||||
|
// Change catalog after prepay; paid grant must still use the original snapshot.
|
||||||
|
if err := db.ORM.Model(&model.StoragePlan{}).Where("key=?", small).Updates(map[string]any{"quota_mb": 99, "months": 9, "price_cents": 500, "enabled": false}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deliver(order)
|
||||||
|
space, err := store.StorageSpace(db.ORM, uid)
|
||||||
|
if err != nil || space.Unlimited || !space.IsVip || space.QuotaMb != 1024 {
|
||||||
|
t.Fatalf("finite allowance: %+v %v", space, err)
|
||||||
|
}
|
||||||
|
partner := request("storage_info", "GET", partnerToken, nil, 200)
|
||||||
|
if store.ToInt(partner["quota_mb"]) != 1024 || partner["is_unlimited"] != false {
|
||||||
|
t.Fatalf("partner allowance mismatch: %+v", partner)
|
||||||
|
}
|
||||||
|
if err := store.CheckFileQuota(db.ORM, uid, model.File{Size: 1025 * 1024 * 1024}); !errors.Is(err, store.ErrUploadQuota) {
|
||||||
|
t.Fatalf("paid finite upload bypassed limit: %v", err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.ORM.Model(&model.StorageEntitlement{}).Where("order_id=?", order.Id).Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatal("replayed callback duplicated capacity")
|
||||||
|
}
|
||||||
|
larger := create(large)
|
||||||
|
deliver(larger)
|
||||||
|
space, err = store.StorageSpace(db.ORM, uid)
|
||||||
|
if err != nil || space.QuotaMb != 5120 || space.Unlimited {
|
||||||
|
t.Fatalf("tier upgrade: %+v %v", space, err)
|
||||||
|
}
|
||||||
|
// Same capacity renewal starts after the previous grant; changing capacity does not replace it.
|
||||||
|
renewal := create(large)
|
||||||
|
deliver(renewal)
|
||||||
|
var first, next model.StorageEntitlement
|
||||||
|
db.ORM.Where("order_id=?", larger.Id).First(&first)
|
||||||
|
db.ORM.Where("order_id=?", renewal.Id).First(&next)
|
||||||
|
if !next.StartsAt.Equal(first.ExpiresAt) || !next.ExpiresAt.Equal(next.StartsAt.AddDate(0, 3, 0)) {
|
||||||
|
t.Fatal("same capacity renewal did not extend correctly")
|
||||||
|
}
|
||||||
|
if err := db.ORM.Model(&model.StorageEntitlement{}).Where("user_id=? AND quota_mb=?", uid, 5120).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
space, err = store.StorageSpace(db.ORM, uid)
|
||||||
|
if err != nil || space.QuotaMb != 1024 {
|
||||||
|
t.Fatalf("tier expiry did not fall back: %+v %v", space, err)
|
||||||
|
}
|
||||||
|
if err := db.ORM.Model(&model.StorageEntitlement{}).Where("user_id=?", uid).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
space, err = store.StorageSpace(db.ORM, uid)
|
||||||
|
if err != nil || space.IsVip || space.QuotaMb != 200 || space.Unlimited {
|
||||||
|
t.Fatalf("expired membership did not return to free quota: %+v %v", space, err)
|
||||||
|
}
|
||||||
|
// Keep the remaining general API fixtures independent of this catalog test.
|
||||||
|
current := request("admin_storage_settings", "GET", "admin", nil, 200)
|
||||||
|
request("admin_storage_settings", "POST", "admin", M{"free_mb": 200, "plans": []M{}, "revision": current["revision"]}, 200)
|
||||||
|
if plans, err := storageCatalog(db.ORM, true); err != nil || len(plans) != 0 {
|
||||||
|
t.Fatal("empty catalog not preserved")
|
||||||
|
}
|
||||||
|
// Restore original soft-deleted fixtures only inside this isolated test schema.
|
||||||
|
_ = db.ORM.Unscoped().Model(&model.StoragePlan{}).Where("key IN ?", []string{"month", "year"}).Update("deleted_at", nil).Error
|
||||||
|
t.Log("multi-tier catalog, 1-cent snapshots, finite quotas, partner sharing, renewals, expiry and replay verified")
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"past/internal/httpapi"
|
||||||
|
"past/internal/model"
|
||||||
|
"past/internal/store"
|
||||||
|
"past/internal/wxpay"
|
||||||
|
)
|
||||||
|
|
||||||
|
func storageWechatOrderCreate(c *httpapi.Ctx, plan model.StoragePlan) error {
|
||||||
|
price := plan.PriceCents
|
||||||
|
if c.App.WechatPay == nil {
|
||||||
|
return c.Err(503, "微信支付服务未配置")
|
||||||
|
}
|
||||||
|
if price <= 0 || price > 100000000 {
|
||||||
|
return c.Err(400, "套餐价格无效")
|
||||||
|
}
|
||||||
|
uid := c.UID()
|
||||||
|
openID := userOpenId(c.DB, uid)
|
||||||
|
if openID == "" {
|
||||||
|
return c.Err(400, "请先使用微信登录后支付")
|
||||||
|
}
|
||||||
|
expires := time.Now().Add(30 * time.Minute)
|
||||||
|
cfg := c.App.Cfg.WechatPay
|
||||||
|
order := model.StorageOrder{UserId: uid, OutTradeNo: "P" + httpapi.NewToken()[:31], PlanKey: plan.Key, PlanName: plan.Name, PlanQuotaMb: plan.QuotaMb, PlanMonths: plan.Months, AmountCents: price, Status: "pending", PayProvider: "wechat", PayAppId: cfg.AppID, PayMchId: cfg.MchID, PayerOpenId: openID, ExpiresAt: &expires}
|
||||||
|
if cid := c.CoupleID(); cid != "" {
|
||||||
|
order.CoupleId = &cid
|
||||||
|
}
|
||||||
|
// Persist before calling WeChat: a lost prepay response can still be reconciled.
|
||||||
|
if err := c.DB.ORM.Create(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
description := "我们past-" + plan.Name
|
||||||
|
for len(description) > 127 {
|
||||||
|
_, size := utf8.DecodeLastRuneInString(description)
|
||||||
|
description = description[:len(description)-size]
|
||||||
|
}
|
||||||
|
res, err := c.App.WechatPay.Prepay(c.R.Context(), wxpay.PrepayOrder{Number: order.OutTradeNo, Description: description, OpenID: openID, Amount: price, Attach: order.Id, ExpiresAt: expires})
|
||||||
|
if err != nil {
|
||||||
|
zap.S().Errorw("微信支付预下单失败", "out_trade_no", order.OutTradeNo, "err", err)
|
||||||
|
return c.Err(502, "微信支付下单失败,请稍后重试")
|
||||||
|
}
|
||||||
|
if res == nil || res.PrepayId == nil || res.Package == nil || res.PaySign == nil || res.TimeStamp == nil || res.NonceStr == nil || res.SignType == nil {
|
||||||
|
return c.Err(502, "微信支付返回参数不完整")
|
||||||
|
}
|
||||||
|
if err := c.DB.ORM.Model(&model.StorageOrder{}).Where("id=?", order.Id).Update("prepay_id", *res.PrepayId).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.OK(M{"out_trade_no": order.OutTradeNo, "pay_provider": "wechat", "payment": M{"time_stamp": *res.TimeStamp, "nonce_str": *res.NonceStr, "package": *res.Package, "sign_type": *res.SignType, "pay_sign": *res.PaySign}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func wechatPayNotify(c *httpapi.Ctx) error {
|
||||||
|
reply := func(status int, code, msg string) error {
|
||||||
|
return c.Raw(status, "application/json; charset=utf-8", []byte(fmt.Sprintf(`{"code":%q,"message":%q}`, code, msg)))
|
||||||
|
}
|
||||||
|
if c.Method != http.MethodPost {
|
||||||
|
return reply(405, "FAIL", "仅支持 POST")
|
||||||
|
}
|
||||||
|
if c.App.WechatPay == nil {
|
||||||
|
return reply(503, "FAIL", "支付服务未配置")
|
||||||
|
}
|
||||||
|
c.R.Body = http.MaxBytesReader(c.W, c.R.Body, 1<<20)
|
||||||
|
tx, err := c.App.WechatPay.ParseNotify(c.R.Context(), c.R)
|
||||||
|
if err != nil {
|
||||||
|
zap.S().Warnw("微信支付通知验签或解密失败", "err", err)
|
||||||
|
return reply(400, "FAIL", "通知验证失败")
|
||||||
|
}
|
||||||
|
if err := deliverWechatStorageOrder(c.DB, tx); err != nil {
|
||||||
|
zap.S().Errorw("微信支付通知处理失败", "err", err)
|
||||||
|
return reply(500, "FAIL", "订单处理失败")
|
||||||
|
}
|
||||||
|
// Only acknowledge after the transaction commits; WeChat retries failed responses.
|
||||||
|
return c.Raw(204, "application/json", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coreValue(v *string) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliverWechatStorageOrder(db *store.DB, tx *payments.Transaction) error {
|
||||||
|
if tx == nil || coreValue(tx.OutTradeNo) == "" || coreValue(tx.TradeState) != "SUCCESS" {
|
||||||
|
return fmt.Errorf("无效的支付成功结果")
|
||||||
|
}
|
||||||
|
return db.ORM.Transaction(func(g *gorm.DB) error {
|
||||||
|
var order model.StorageOrder
|
||||||
|
if err := g.Clauses(clause.Locking{Strength: "UPDATE"}).Where("out_trade_no=?", *tx.OutTradeNo).First(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.PayProvider != "wechat" || coreValue(tx.Appid) != order.PayAppId || coreValue(tx.Mchid) != order.PayMchId || order.PayAppId == "" || order.PayMchId == "" || coreValue(tx.TradeType) != "JSAPI" || tx.Amount == nil || tx.Amount.Total == nil || *tx.Amount.Total != order.AmountCents || coreValue(tx.Amount.Currency) != "CNY" || tx.Payer == nil || coreValue(tx.Payer.Openid) != order.PayerOpenId || order.PayerOpenId == "" || coreValue(tx.TransactionId) == "" {
|
||||||
|
return fmt.Errorf("微信支付身份、币种或订单金额不匹配")
|
||||||
|
}
|
||||||
|
paidAt, err := time.Parse(time.RFC3339, coreValue(tx.SuccessTime))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无效的支付时间")
|
||||||
|
}
|
||||||
|
if order.Status == "success" {
|
||||||
|
if order.WxOrderId != *tx.TransactionId {
|
||||||
|
return fmt.Errorf("订单已由其他交易支付")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Serialize duplicate transaction IDs across different local orders.
|
||||||
|
if err := g.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", "wxpay:"+*tx.TransactionId).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := g.Model(&model.StorageOrder{}).Where("wx_order_id=? AND pay_provider='wechat' AND id<>?", *tx.TransactionId, order.Id).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return fmt.Errorf("微信交易号已处理")
|
||||||
|
}
|
||||||
|
if err := grantStorageMembership(g, &order); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return g.Model(&order).Updates(map[string]any{"status": "success", "wx_order_id": *tx.TransactionId, "paid_at": paidAt}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantStorageMembership(tx *gorm.DB, order *model.StorageOrder) error {
|
||||||
|
months, quota, name := order.PlanMonths, order.PlanQuotaMb, order.PlanName
|
||||||
|
if months == 0 {
|
||||||
|
switch order.PlanKey {
|
||||||
|
case "month":
|
||||||
|
months = 1
|
||||||
|
case "year":
|
||||||
|
months = 12
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("无效的订单套餐快照")
|
||||||
|
}
|
||||||
|
quota = 0
|
||||||
|
name = storagePlanLabel(order.PlanKey)
|
||||||
|
}
|
||||||
|
if months < 1 || months > 120 || quota < 0 || quota > 104857600 || name == "" {
|
||||||
|
return fmt.Errorf("无效的订单套餐快照")
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
rUser := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", order.UserId).Limit(1).Find(&user)
|
||||||
|
if rUser.Error != nil {
|
||||||
|
return rUser.Error
|
||||||
|
}
|
||||||
|
if rUser.RowsAffected != 1 {
|
||||||
|
return fmt.Errorf("订单用户不存在")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
// Preserve an old unlimited membership before changing its denormalized fields.
|
||||||
|
if (user.StoragePlan == "" || user.StoragePlan == "month" || user.StoragePlan == "year") && user.StorageVipExpireAt != nil && user.StorageVipExpireAt.After(now) {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&model.StorageEntitlement{}).Where("user_id=?", user.Id).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
legacy := model.StorageEntitlement{UserId: user.Id, SourceKey: "legacy:" + user.Id, PlanKey: user.StoragePlan, PlanName: "原无限空间会员", StartsAt: now, ExpiresAt: *user.StorageVipExpireAt}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&legacy).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := now
|
||||||
|
var previous model.StorageEntitlement
|
||||||
|
r := tx.Where("user_id=? AND quota_mb=? AND expires_at>?", user.Id, quota, now).Order("expires_at DESC").Limit(1).Find(&previous)
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
if r.RowsAffected > 0 {
|
||||||
|
start = previous.ExpiresAt
|
||||||
|
}
|
||||||
|
expires := storageExpiry(start, months)
|
||||||
|
ent := model.StorageEntitlement{UserId: user.Id, SourceKey: "order:" + order.Id, PlanKey: order.PlanKey, PlanName: name, QuotaMb: quota, StartsAt: start, ExpiresAt: expires}
|
||||||
|
if store.ValidUUID(order.Id) {
|
||||||
|
ent.OrderId = &order.Id
|
||||||
|
}
|
||||||
|
if err := tx.Create(&ent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&user).Updates(map[string]any{"storage_plan": order.PlanKey, "storage_plan_name": name, "storage_plan_started_at": now, "storage_vip_expire_at": expires}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageOrderStatus(c *httpapi.Ctx) error {
|
||||||
|
number := c.Q("out_trade_no")
|
||||||
|
if number == "" {
|
||||||
|
number = c.InTrim("out_trade_no")
|
||||||
|
}
|
||||||
|
var order model.StorageOrder
|
||||||
|
result := c.DB.ORM.Where("out_trade_no=? AND user_id=?", number, c.UID()).Limit(1).Find(&order)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return c.Err(404, "订单不存在")
|
||||||
|
}
|
||||||
|
if order.PayProvider == "wechat" && order.Status == "pending" {
|
||||||
|
if c.App.WechatPay == nil {
|
||||||
|
return c.Err(503, "微信支付服务未配置")
|
||||||
|
}
|
||||||
|
if err := reconcileWechatOrder(c.R.Context(), c.DB, c.App.WechatPay, order.OutTradeNo); err != nil {
|
||||||
|
zap.S().Warnw("微信支付主动查单失败", "out_trade_no", order.OutTradeNo, "err", err)
|
||||||
|
return c.Err(502, "支付结果暂未确认,请稍后重试")
|
||||||
|
}
|
||||||
|
if err := c.DB.ORM.First(&order, "id=?", order.Id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.OK(M{"out_trade_no": order.OutTradeNo, "status": order.Status, "pay_provider": order.PayProvider, "paid_at": order.PaidAt})
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileWechatOrder(ctx context.Context, db *store.DB, gateway wxpay.Gateway, number string) error {
|
||||||
|
tx, err := gateway.Query(ctx, number)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tx == nil || coreValue(tx.OutTradeNo) != number {
|
||||||
|
return fmt.Errorf("查单返回订单号不匹配")
|
||||||
|
}
|
||||||
|
if coreValue(tx.TradeState) == "SUCCESS" {
|
||||||
|
return deliverWechatStorageOrder(db, tx)
|
||||||
|
}
|
||||||
|
if coreValue(tx.TradeState) == "CLOSED" || coreValue(tx.TradeState) == "REVOKED" || coreValue(tx.TradeState) == "PAYERROR" {
|
||||||
|
return db.ORM.Model(&model.StorageOrder{}).Where("out_trade_no=? AND pay_provider='wechat' AND status='pending'", number).Update("status", "closed").Error
|
||||||
|
}
|
||||||
|
if coreValue(tx.TradeState) != "NOTPAY" && coreValue(tx.TradeState) != "USERPAYING" {
|
||||||
|
return fmt.Errorf("未知的微信支付订单状态")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWechatOrderQuery compensates for lost callbacks and stops with the server.
|
||||||
|
func RunWechatOrderQuery(db *store.DB, gateway wxpay.Gateway, stop <-chan struct{}, interval time.Duration) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
cancel()
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Minute
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
var orders []model.StorageOrder
|
||||||
|
err := db.ORM.WithContext(ctx).Where("pay_provider='wechat' AND status='pending' AND created_at>NOW()-INTERVAL '24 hour'").Order("created_at ASC").Limit(50).Find(&orders).Error
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
|
zap.S().Errorw("微信支付待支付订单查询失败", "err", err)
|
||||||
|
}
|
||||||
|
for _, order := range orders {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queryCtx, done := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
err := reconcileWechatOrder(queryCtx, db, gateway, order.OutTradeNo)
|
||||||
|
done()
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
|
zap.S().Warnw("微信支付补偿查单失败", "out_trade_no", order.OutTradeNo, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||||
|
"past/internal/config"
|
||||||
|
"past/internal/httpapi"
|
||||||
|
"past/internal/model"
|
||||||
|
"past/internal/store"
|
||||||
|
"past/internal/wxpay"
|
||||||
|
)
|
||||||
|
|
||||||
|
type paymentFake struct {
|
||||||
|
prepayErr error
|
||||||
|
query *payments.Transaction
|
||||||
|
notify *payments.Transaction
|
||||||
|
notifyErr error
|
||||||
|
last wxpay.PrepayOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentFake) Prepay(_ context.Context, o wxpay.PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error) {
|
||||||
|
f.last = o
|
||||||
|
if f.prepayErr != nil {
|
||||||
|
return nil, f.prepayErr
|
||||||
|
}
|
||||||
|
return &jsapi.PrepayWithRequestPaymentResponse{PrepayId: core.String("prepay-test"), TimeStamp: core.String("123456"), NonceStr: core.String("nonce"), Package: core.String("prepay_id=prepay-test"), SignType: core.String("RSA"), PaySign: core.String("signature")}, nil
|
||||||
|
}
|
||||||
|
func (f *paymentFake) Query(context.Context, string) (*payments.Transaction, error) {
|
||||||
|
return f.query, nil
|
||||||
|
}
|
||||||
|
func (f *paymentFake) ParseNotify(context.Context, *http.Request) (*payments.Transaction, error) {
|
||||||
|
return f.notify, f.notifyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func exerciseWechatPayment(t *testing.T, db *store.DB, app *httpapi.App, engine http.Handler, request func(string, string, string, M, int) M, covered map[string]int, happy map[string]bool, uid, token, otherToken string) {
|
||||||
|
t.Helper()
|
||||||
|
oldCfg := app.Cfg.WechatPay
|
||||||
|
defer func() { app.Cfg.WechatPay = oldCfg; app.WechatPay = nil }()
|
||||||
|
app.Cfg.WechatPay = config.WechatPayConfig{Enabled: true, AppID: "wx-pay-test", MchID: "merchant-test"}
|
||||||
|
gateway := &paymentFake{}
|
||||||
|
app.WechatPay = gateway
|
||||||
|
if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Updates(map[string]any{"openid": "pay-openid", "storage_vip_expire_at": time.Now().AddDate(0, -3, 0)}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.ORM.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
create := func(plan string) model.StorageOrder {
|
||||||
|
action := "storage_create_order"
|
||||||
|
if plan == "year" {
|
||||||
|
action = "storage_order_create"
|
||||||
|
}
|
||||||
|
r := request(action, "POST", token, M{"plan_key": plan, "amount_cents": 1}, 200)
|
||||||
|
if r["pay_provider"] != "wechat" || r["payment"].(M)["package"] != "prepay_id=prepay-test" {
|
||||||
|
t.Fatalf("bad payment params: %+v", r)
|
||||||
|
}
|
||||||
|
var o model.StorageOrder
|
||||||
|
if err := db.ORM.Where("out_trade_no=?", r["out_trade_no"]).First(&o).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o.AmountCents != gateway.last.Amount || o.AmountCents <= 1 || o.PrepayId != "prepay-test" || o.PayerOpenId != "pay-openid" || o.PayAppId != "wx-pay-test" || o.PayMchId != "merchant-test" || o.PayProvider != "wechat" {
|
||||||
|
t.Fatalf("incorrect payment snapshot: %+v", o)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
order := create("month")
|
||||||
|
transaction := func(o model.StorageOrder) *payments.Transaction {
|
||||||
|
return &payments.Transaction{Appid: core.String(o.PayAppId), Mchid: core.String(o.PayMchId), OutTradeNo: core.String(o.OutTradeNo), TransactionId: core.String("wx-" + o.OutTradeNo), TradeState: core.String("SUCCESS"), TradeType: core.String("JSAPI"), SuccessTime: core.String(time.Now().Format(time.RFC3339)), Amount: &payments.TransactionAmount{Total: core.Int64(o.AmountCents), Currency: core.String("CNY")}, Payer: &payments.TransactionPayer{Openid: core.String(o.PayerOpenId)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
request("storage_order_status", "GET", otherToken, M{"out_trade_no": order.OutTradeNo}, 404)
|
||||||
|
for _, field := range []string{"amount", "currency", "appid", "merchant", "payer", "state", "trade_type", "time", "nil_amount", "transaction"} {
|
||||||
|
tx := transaction(order)
|
||||||
|
switch field {
|
||||||
|
case "amount":
|
||||||
|
tx.Amount.Total = core.Int64(1)
|
||||||
|
case "currency":
|
||||||
|
tx.Amount.Currency = core.String("USD")
|
||||||
|
case "appid":
|
||||||
|
tx.Appid = core.String("foreign-app")
|
||||||
|
case "merchant":
|
||||||
|
tx.Mchid = core.String("foreign-merchant")
|
||||||
|
case "payer":
|
||||||
|
tx.Payer.Openid = core.String("foreign-payer")
|
||||||
|
case "state":
|
||||||
|
tx.TradeState = core.String("NOTPAY")
|
||||||
|
case "trade_type":
|
||||||
|
tx.TradeType = core.String("NATIVE")
|
||||||
|
case "time":
|
||||||
|
tx.SuccessTime = nil
|
||||||
|
case "nil_amount":
|
||||||
|
tx.Amount = nil
|
||||||
|
case "transaction":
|
||||||
|
tx.TransactionId = nil
|
||||||
|
}
|
||||||
|
if err := deliverWechatStorageOrder(db, tx); err == nil {
|
||||||
|
t.Fatalf("invalid %s accepted", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback := func(want int) {
|
||||||
|
r := httptest.NewRequest("POST", "/api/wechatPayNotify", strings.NewReader(`{}`))
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
out := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(out, r)
|
||||||
|
covered["wechat_pay_notify"]++
|
||||||
|
if out.Code != want {
|
||||||
|
t.Fatalf("wechat callback %d want %d: %s", out.Code, want, out.Body.String())
|
||||||
|
}
|
||||||
|
if out.Code == 204 {
|
||||||
|
happy["wechat_pay_notify"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gateway.notifyErr = errors.New("bad signature")
|
||||||
|
callback(400)
|
||||||
|
gateway.notifyErr = nil
|
||||||
|
gateway.notify = transaction(order)
|
||||||
|
gateway.notify.Amount.Total = core.Int64(1)
|
||||||
|
callback(500)
|
||||||
|
var unpaid model.StorageOrder
|
||||||
|
db.ORM.First(&unpaid, "id=?", order.Id)
|
||||||
|
if unpaid.Status != "pending" {
|
||||||
|
t.Fatal("invalid notify changed status")
|
||||||
|
}
|
||||||
|
gateway.query = transaction(order)
|
||||||
|
gateway.query.TradeState = core.String("NOTPAY")
|
||||||
|
request("storage_order_status", "GET", token, M{"out_trade_no": order.OutTradeNo}, 200)
|
||||||
|
gateway.notify = transaction(order)
|
||||||
|
// Simultaneous authentic notifications are fulfilled exactly once.
|
||||||
|
results := make(chan error, 6)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); results <- deliverWechatStorageOrder(db, transaction(order)) }()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
for err := range results {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback(204)
|
||||||
|
callback(204)
|
||||||
|
var user model.User
|
||||||
|
db.ORM.First(&user, "id=?", uid)
|
||||||
|
target := time.Now().AddDate(0, 1, 0)
|
||||||
|
if user.StorageVipExpireAt == nil || user.StorageVipExpireAt.Before(target.Add(-time.Minute)) || user.StorageVipExpireAt.After(target.Add(time.Minute)) || user.StoragePlan != "month" {
|
||||||
|
t.Fatalf("expired VIP renewal or idempotency failed: %+v", user.StorageVipExpireAt)
|
||||||
|
}
|
||||||
|
before := *user.StorageVipExpireAt
|
||||||
|
// Active query recovers a lost callback; requesting status again cannot extend VIP.
|
||||||
|
annual := create("year")
|
||||||
|
gateway.query = transaction(annual)
|
||||||
|
r := request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200)
|
||||||
|
if r["status"] != "success" {
|
||||||
|
t.Fatalf("query did not deliver: %+v", r)
|
||||||
|
}
|
||||||
|
request("storage_order_status", "GET", token, M{"out_trade_no": annual.OutTradeNo}, 200)
|
||||||
|
db.ORM.First(&user, "id=?", uid)
|
||||||
|
if !user.StorageVipExpireAt.Equal(before.AddDate(1, 0, 0)) {
|
||||||
|
t.Fatalf("annual membership wrong: %v", user.StorageVipExpireAt)
|
||||||
|
}
|
||||||
|
// A successful WeChat transaction cannot be applied to another order.
|
||||||
|
duplicate := create("month")
|
||||||
|
same := transaction(duplicate)
|
||||||
|
same.TransactionId = transaction(order).TransactionId
|
||||||
|
if err := deliverWechatStorageOrder(db, same); err == nil {
|
||||||
|
t.Fatal("duplicate WeChat transaction accepted")
|
||||||
|
}
|
||||||
|
gateway.query = transaction(duplicate)
|
||||||
|
gateway.query.TradeState = core.String("CLOSED")
|
||||||
|
r = request("storage_order_status", "GET", token, M{"out_trade_no": duplicate.OutTradeNo}, 200)
|
||||||
|
if r["status"] != "closed" {
|
||||||
|
t.Fatal("closed order not recorded")
|
||||||
|
}
|
||||||
|
// Transactional rollback if the owner has been deleted.
|
||||||
|
vanished := create("month")
|
||||||
|
var beforeDeleted model.User
|
||||||
|
db.ORM.First(&beforeDeleted, "id=?", uid)
|
||||||
|
if err := db.ORM.Model(&model.User{}).Where("id=?", uid).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := deliverWechatStorageOrder(db, transaction(vanished)); err == nil {
|
||||||
|
t.Fatal("deleted owner granted membership")
|
||||||
|
}
|
||||||
|
db.ORM.Unscoped().Model(&model.User{}).Where("id=?", uid).Update("deleted_at", nil)
|
||||||
|
var rolledBack model.StorageOrder
|
||||||
|
db.ORM.First(&rolledBack, "id=?", vanished.Id)
|
||||||
|
if rolledBack.Status != "pending" {
|
||||||
|
t.Fatal("failed grant marked paid")
|
||||||
|
}
|
||||||
|
// Search by order number, UUID, and public ID must all stay valid SQL.
|
||||||
|
var owner model.User
|
||||||
|
db.ORM.First(&owner, "id=?", uid)
|
||||||
|
for _, term := range []string{order.OutTradeNo, uid, owner.UserId} {
|
||||||
|
request("admin_storage_orders", "GET", "admin", M{"search": term}, 200)
|
||||||
|
}
|
||||||
|
t.Log(fmt.Sprintf("WeChat payment snapshots, validation, replay safety and query recovery verified"))
|
||||||
|
}
|
||||||
@@ -18,10 +18,12 @@ import (
|
|||||||
"past/internal/store"
|
"past/internal/store"
|
||||||
"past/internal/upload"
|
"past/internal/upload"
|
||||||
"past/internal/wechat"
|
"past/internal/wechat"
|
||||||
|
"past/internal/wxpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App 全局依赖
|
// App 全局依赖
|
||||||
type App struct {
|
type App struct {
|
||||||
|
WechatPay wxpay.Gateway
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
DB *store.DB
|
DB *store.DB
|
||||||
Wechat *wechat.Client
|
Wechat *wechat.Client
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestPublishedUnsignedPhotoURLsStillLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) {
|
func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) {
|
||||||
resource := "uploads/users/1/diaries/a.jpg"
|
resource := "uploads/users/1/diaries/a.jpg"
|
||||||
signed := "https://past.sundynix.cn/backend/" + resource + "?" + url.Values{"media_sig": {"test"}, "media_uid": {"1"}}.Encode()
|
signed := "https://we.sundynix.cn/backend/" + resource + "?" + url.Values{"media_sig": {"test"}, "media_uid": {"1"}}.Encode()
|
||||||
input := map[string]any{"images": []any{signed}, "encoded": "[\"" + signed + "\"]", "content": "plain text"}
|
input := map[string]any{"images": []any{signed}, "encoded": "[\"" + signed + "\"]", "content": "plain text"}
|
||||||
normalized := normalizeMediaInput(input).(map[string]any)
|
normalized := normalizeMediaInput(input).(map[string]any)
|
||||||
if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" {
|
if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go.uber.org/zap"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -55,7 +55,7 @@ func (r *Router) Actions() []string {
|
|||||||
|
|
||||||
// 与 index.php 中 $publicActions / $adminActions 完全一致
|
// 与 index.php 中 $publicActions / $adminActions 完全一致
|
||||||
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
|
var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login",
|
||||||
"check_invite_code", "register_by_invite", "check_auth", "storage_pay_notify")
|
"check_invite_code", "register_by_invite", "check_auth", "wechat_pay_notify")
|
||||||
|
|
||||||
var adminActions = set("admin_files", "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")
|
var adminActions = set("admin_files", "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")
|
||||||
|
|
||||||
@@ -77,7 +77,12 @@ func (r *Router) Engine(debugMode bool) *gin.Engine {
|
|||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
e := gin.New()
|
e := gin.New()
|
||||||
e.Use(gin.Recovery(), accessLog())
|
gin.DefaultWriter = zap.NewStdLog(zap.L()).Writer()
|
||||||
|
gin.DefaultErrorWriter = zap.NewStdLog(zap.L()).Writer()
|
||||||
|
e.Use(gin.CustomRecovery(func(c *gin.Context, recovered any) {
|
||||||
|
zap.S().Errorw("http panic", "err", recovered, "stack", string(debug.Stack()))
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
}), accessLog())
|
||||||
e.RedirectTrailingSlash = false
|
e.RedirectTrailingSlash = false
|
||||||
e.MaxMultipartMemory = 32 << 20
|
e.MaxMultipartMemory = 32 << 20
|
||||||
|
|
||||||
@@ -164,7 +169,7 @@ func (r *Router) serveAPI(g *gin.Context) {
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if rec := recover(); rec != nil {
|
if rec := recover(); rec != nil {
|
||||||
slog.Error("panic", "action", action, "err", rec, "stack", string(debug.Stack()))
|
zap.S().Errorw("panic", "action", action, "err", rec, "stack", string(debug.Stack()))
|
||||||
if !c.responded {
|
if !c.responded {
|
||||||
c.Err(500, fmt.Sprint(rec))
|
c.Err(500, fmt.Sprint(rec))
|
||||||
}
|
}
|
||||||
@@ -204,7 +209,7 @@ func (r *Router) serveAPI(g *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := hf(c); err != nil && !IsResponded(err) {
|
if err := hf(c); err != nil && !IsResponded(err) {
|
||||||
slog.Error("handler error", "action", action, "err", err)
|
zap.S().Errorw("handler error", "action", action, "err", err)
|
||||||
c.Err(500, err.Error())
|
c.Err(500, err.Error())
|
||||||
}
|
}
|
||||||
if !c.responded {
|
if !c.responded {
|
||||||
@@ -213,7 +218,7 @@ func (r *Router) serveAPI(g *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// accessLog gin 中间件:结构化访问日志(slog)
|
// accessLog gin 中间件:结构化访问日志(zap)
|
||||||
func accessLog() gin.HandlerFunc {
|
func accessLog() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -225,7 +230,7 @@ func accessLog() gin.HandlerFunc {
|
|||||||
if uid, ok := c.Get("uid"); ok {
|
if uid, ok := c.Get("uid"); ok {
|
||||||
attrs = append(attrs, "uid", uid)
|
attrs = append(attrs, "uid", uid)
|
||||||
}
|
}
|
||||||
slog.Info("http", attrs...)
|
zap.S().Infow("http", attrs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Package logging owns zap initialization, rotation and shutdown.
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
"past/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New writes JSON files in log/YYYY-MM-DD/{level}.log, and optionally to stdout.
|
||||||
|
func New(dir string, cfg config.LogConfig) (*zap.Logger, func(), error) {
|
||||||
|
var minimum zapcore.Level
|
||||||
|
if err := minimum.UnmarshalText([]byte(cfg.Level)); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("日志级别无效: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.RetentionDays <= 0 || cfg.MaxSizeMB <= 0 || cfg.MaxBackups <= 0 {
|
||||||
|
return nil, nil, fmt.Errorf("日志保留天数、大小和备份数量必须大于零")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
enc := zap.NewProductionEncoderConfig()
|
||||||
|
enc.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
cores := []zapcore.Core{}
|
||||||
|
writers := []*dailyWriter{}
|
||||||
|
for level := zapcore.DebugLevel; level <= zapcore.FatalLevel; level++ {
|
||||||
|
if level < minimum {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w := &dailyWriter{dir: dir, level: level.String(), cfg: cfg, now: time.Now}
|
||||||
|
// Open eagerly so an unwritable directory fails startup instead of losing logs.
|
||||||
|
if err := w.rotate(); err != nil {
|
||||||
|
for _, old := range writers {
|
||||||
|
old.Close()
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
writers = append(writers, w)
|
||||||
|
exact := level
|
||||||
|
cores = append(cores, zapcore.NewCore(zapcore.NewJSONEncoder(enc), zapcore.AddSync(w), zap.LevelEnablerFunc(func(l zapcore.Level) bool { return l == exact })))
|
||||||
|
}
|
||||||
|
if cfg.Console {
|
||||||
|
cores = append(cores, zapcore.NewCore(zapcore.NewConsoleEncoder(enc), zapcore.Lock(os.Stdout), minimum))
|
||||||
|
}
|
||||||
|
log := zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
|
||||||
|
cleanup := func() {
|
||||||
|
_ = log.Sync()
|
||||||
|
for _, w := range writers {
|
||||||
|
_ = w.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return log, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dir, level, day string
|
||||||
|
cfg config.LogConfig
|
||||||
|
now func() time.Time
|
||||||
|
writer *lumberjack.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds mu (initialization happens before the writer is shared).
|
||||||
|
func (w *dailyWriter) rotate() error {
|
||||||
|
now := w.now()
|
||||||
|
day := now.Format(time.DateOnly)
|
||||||
|
if day == w.day && w.writer != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(w.dir, day)
|
||||||
|
if err := os.MkdirAll(path, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filename := filepath.Join(path, w.level+".log")
|
||||||
|
f, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
if w.writer != nil {
|
||||||
|
_ = w.writer.Close()
|
||||||
|
}
|
||||||
|
w.writer = &lumberjack.Logger{Filename: filename, MaxSize: w.cfg.MaxSizeMB, MaxAge: w.cfg.RetentionDays, MaxBackups: w.cfg.MaxBackups, Compress: true, LocalTime: true}
|
||||||
|
w.day = day
|
||||||
|
entries, err := os.ReadDir(w.dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cutoff := now.AddDate(0, 0, -w.cfg.RetentionDays).Format(time.DateOnly)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := time.Parse(time.DateOnly, entry.Name()); err == nil && entry.Name() < cutoff {
|
||||||
|
if err := os.RemoveAll(filepath.Join(w.dir, entry.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (w *dailyWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if err := w.rotate(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return w.writer.Write(p)
|
||||||
|
}
|
||||||
|
func (w *dailyWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.writer != nil {
|
||||||
|
return w.writer.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"past/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLevelFilesAndDailyRotation(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "log")
|
||||||
|
cfg := config.LogConfig{Level: "info", RetentionDays: 30, MaxSizeMB: 1, MaxBackups: 2}
|
||||||
|
logger, closeLog, err := New(dir, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger.Info("started", zap.String("component", "redis"))
|
||||||
|
logger.Warn("retry")
|
||||||
|
logger.Error("failure")
|
||||||
|
logger.Debug("hidden")
|
||||||
|
closeLog()
|
||||||
|
for _, level := range []string{"info", "warn", "error"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, time.Now().Format(time.DateOnly), level+".log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(data, &entry); err != nil {
|
||||||
|
t.Fatalf("invalid structured log: %s", data)
|
||||||
|
}
|
||||||
|
if entry["level"] != level || entry["caller"] == nil {
|
||||||
|
t.Fatalf("missing level/caller: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := &dailyWriter{dir: dir, level: "info", cfg: cfg, now: func() time.Time { return time.Date(2026, 11, 1, 23, 59, 0, 0, time.Local) }}
|
||||||
|
defer w.Close()
|
||||||
|
if _, err := w.Write([]byte("before midnight\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.now = func() time.Time { return time.Date(2026, 11, 2, 0, 1, 0, 0, time.Local) }
|
||||||
|
if _, err := w.Write([]byte("after midnight\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "2026-11-02", "info.log"))
|
||||||
|
if err != nil || !strings.Contains(string(data), "after midnight") {
|
||||||
|
t.Fatal("daily rotation failed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,8 @@ type User struct {
|
|||||||
SessionKey string `gorm:"column:session_key;type:varchar(128);default:''" json:"session_key"`
|
SessionKey string `gorm:"column:session_key;type:varchar(128);default:''" json:"session_key"`
|
||||||
StorageVipExpireAt *time.Time `gorm:"column:storage_vip_expire_at;type:timestamptz" json:"storage_vip_expire_at"`
|
StorageVipExpireAt *time.Time `gorm:"column:storage_vip_expire_at;type:timestamptz" json:"storage_vip_expire_at"`
|
||||||
StorageQuotaMb int64 `gorm:"column:storage_quota_mb;type:bigint;default:0" json:"storage_quota_mb"`
|
StorageQuotaMb int64 `gorm:"column:storage_quota_mb;type:bigint;default:0" json:"storage_quota_mb"`
|
||||||
StoragePlan string `gorm:"column:storage_plan;type:varchar(20);default:''" json:"storage_plan"`
|
StoragePlanName string `gorm:"type:varchar(80);not null;default:''" json:"storage_plan_name"`
|
||||||
|
StoragePlan string `gorm:"column:storage_plan;type:varchar(64);default:''" json:"storage_plan"`
|
||||||
StoragePlanStartedAt *time.Time `gorm:"column:storage_plan_started_at;type:timestamptz" json:"storage_plan_started_at"`
|
StoragePlanStartedAt *time.Time `gorm:"column:storage_plan_started_at;type:timestamptz" json:"storage_plan_started_at"`
|
||||||
SmokingCardVisible int64 `gorm:"column:smoking_card_visible;type:bigint;not null;default:1" json:"smoking_card_visible"`
|
SmokingCardVisible int64 `gorm:"column:smoking_card_visible;type:bigint;not null;default:1" json:"smoking_card_visible"`
|
||||||
AvatarFileId *string `gorm:"type:uuid;index" json:"avatar_file_id"`
|
AvatarFileId *string `gorm:"type:uuid;index" json:"avatar_file_id"`
|
||||||
@@ -116,7 +117,7 @@ type Timeline struct {
|
|||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
CoupleId string `gorm:"column:couple_id;type:uuid;not null" json:"couple_id"`
|
CoupleId *string `gorm:"column:couple_id;type:uuid;index" json:"couple_id"`
|
||||||
Title string `gorm:"column:title;type:varchar(255);not null" json:"title"`
|
Title string `gorm:"column:title;type:varchar(255);not null" json:"title"`
|
||||||
Description string `gorm:"column:description;type:text" json:"description"`
|
Description string `gorm:"column:description;type:text" json:"description"`
|
||||||
Cover string `gorm:"column:cover;type:varchar(500);default:''" json:"cover"`
|
Cover string `gorm:"column:cover;type:varchar(500);default:''" json:"cover"`
|
||||||
@@ -415,15 +416,46 @@ type PetDynamic struct {
|
|||||||
Message string `gorm:"column:message;type:text" json:"message"`
|
Message string `gorm:"column:message;type:text" json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageOrder struct {
|
type StoragePlan struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
Key string `gorm:"type:varchar(64);not null;uniqueIndex" json:"key"`
|
||||||
|
Name string `gorm:"type:varchar(80);not null" json:"name"`
|
||||||
|
QuotaMb int64 `gorm:"not null" json:"quota_mb"` // 0 = unlimited
|
||||||
|
Months int64 `gorm:"not null" json:"months"`
|
||||||
|
PriceCents int64 `gorm:"not null" json:"price_cents"`
|
||||||
|
Enabled bool `gorm:"not null" json:"enabled"`
|
||||||
|
SortOrder int64 `gorm:"not null;default:0" json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entitlements are immutable purchase snapshots; different capacities expire independently.
|
||||||
|
type StorageEntitlement struct {
|
||||||
|
BaseModel
|
||||||
|
UserId string `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||||
|
OrderId *string `gorm:"type:uuid;uniqueIndex" json:"order_id"`
|
||||||
|
SourceKey string `gorm:"type:varchar(100);uniqueIndex;not null" json:"source_key"`
|
||||||
|
PlanKey string `gorm:"type:varchar(64);not null" json:"plan_key"`
|
||||||
|
PlanName string `gorm:"type:varchar(80);not null" json:"plan_name"`
|
||||||
|
QuotaMb int64 `gorm:"not null" json:"quota_mb"`
|
||||||
|
StartsAt time.Time `gorm:"type:timestamptz;not null" json:"starts_at"`
|
||||||
|
ExpiresAt time.Time `gorm:"type:timestamptz;not null;index" json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageOrder struct {
|
||||||
|
PlanName string `gorm:"type:varchar(80);not null;default:''" json:"plan_name"`
|
||||||
|
PlanQuotaMb int64 `gorm:"not null;default:0" json:"plan_quota_mb"`
|
||||||
|
PlanMonths int64 `gorm:"not null;default:0" json:"plan_months"`
|
||||||
|
BaseModel
|
||||||
|
PayProvider string `gorm:"type:varchar(20);not null;default:'wechat'" json:"pay_provider"`
|
||||||
|
PayAppId string `gorm:"type:varchar(64);not null;default:''" json:"pay_app_id"`
|
||||||
|
PayMchId string `gorm:"type:varchar(32);not null;default:''" json:"pay_mch_id"`
|
||||||
|
PayerOpenId string `gorm:"type:varchar(128);not null;default:''" json:"payer_open_id"`
|
||||||
|
PrepayId string `gorm:"type:varchar(128);not null;default:''" json:"prepay_id"`
|
||||||
|
ExpiresAt *time.Time `gorm:"type:timestamptz" json:"expires_at"`
|
||||||
UserId string `gorm:"column:user_id;type:uuid;not null;index:ix_storage_orders_idx_user,priority:1" json:"user_id"`
|
UserId string `gorm:"column:user_id;type:uuid;not null;index:ix_storage_orders_idx_user,priority:1" json:"user_id"`
|
||||||
CoupleId *string `gorm:"column:couple_id;type:uuid" json:"couple_id"`
|
CoupleId *string `gorm:"column:couple_id;type:uuid" json:"couple_id"`
|
||||||
OutTradeNo string `gorm:"column:out_trade_no;type:varchar(64);not null;uniqueIndex:ux_storage_orders_out_trade_no,where:deleted_at IS NULL" json:"out_trade_no"`
|
OutTradeNo string `gorm:"column:out_trade_no;type:varchar(64);not null;uniqueIndex:ux_storage_orders_out_trade_no,where:deleted_at IS NULL" json:"out_trade_no"`
|
||||||
PlanKey string `gorm:"column:plan_key;type:varchar(20);not null" json:"plan_key"`
|
PlanKey string `gorm:"column:plan_key;type:varchar(64);not null" json:"plan_key"`
|
||||||
ProductId string `gorm:"column:product_id;type:varchar(64);not null;default:''" json:"product_id"`
|
|
||||||
AmountCents int64 `gorm:"column:amount_cents;type:bigint;not null;default:0" json:"amount_cents"`
|
AmountCents int64 `gorm:"column:amount_cents;type:bigint;not null;default:0" json:"amount_cents"`
|
||||||
Env int64 `gorm:"column:env;type:bigint;not null;default:1" json:"env"`
|
|
||||||
Status string `gorm:"column:status;type:varchar(20);not null;default:'pending';index:ix_storage_orders_idx_status,priority:1" json:"status"`
|
Status string `gorm:"column:status;type:varchar(20);not null;default:'pending';index:ix_storage_orders_idx_status,priority:1" json:"status"`
|
||||||
WxOrderId string `gorm:"column:wx_order_id;type:varchar(64);not null;default:''" json:"wx_order_id"`
|
WxOrderId string `gorm:"column:wx_order_id;type:varchar(64);not null;default:''" json:"wx_order_id"`
|
||||||
Attach string `gorm:"column:attach;type:text" json:"attach"`
|
Attach string `gorm:"column:attach;type:text" json:"attach"`
|
||||||
@@ -465,10 +497,10 @@ type SmokingEntry struct {
|
|||||||
|
|
||||||
// Models is the complete AutoMigrate registry.
|
// Models is the complete AutoMigrate registry.
|
||||||
func Models() []any {
|
func Models() []any {
|
||||||
return []any{&File{}, &FileUpload{}, &RecordFile{}, &UnbindingFileCleanup{}, &User{}, &Couple{}, &Invitation{}, &UnbindRequest{}, &InviteLink{}, &Timeline{}, &Album{}, &Photo{}, &Diary{}, &Song{}, &Checkin{}, &CheckinLog{}, &SmokingAccount{}, &SmokingLog{}, &PeriodRecord{}, &PeriodDailyRecord{}, &PeriodSexRecord{}, &Setting{}, &DiaryComment{}, &SubscribeTemplate{}, &ActivityLog{}, &Admin{}, &UserToken{}, &SubscribeMessageLog{}, &NotifyLog{}, &Pet{}, &PetDaily{}, &PetAchievement{}, &PetItem{}, &PetDynamic{}, &StorageOrder{}, &DiaryLike{}, &DiaryFavorite{}, &SmokingWallet{}, &SmokingEntry{}}
|
return []any{&File{}, &FileUpload{}, &RecordFile{}, &UnbindingFileCleanup{}, &User{}, &Couple{}, &Invitation{}, &UnbindRequest{}, &InviteLink{}, &Timeline{}, &Album{}, &Photo{}, &Diary{}, &Song{}, &Checkin{}, &CheckinLog{}, &SmokingAccount{}, &SmokingLog{}, &PeriodRecord{}, &PeriodDailyRecord{}, &PeriodSexRecord{}, &Setting{}, &DiaryComment{}, &SubscribeTemplate{}, &ActivityLog{}, &Admin{}, &UserToken{}, &SubscribeMessageLog{}, &NotifyLog{}, &Pet{}, &PetDaily{}, &PetAchievement{}, &PetItem{}, &PetDynamic{}, &StorageOrder{}, &StoragePlan{}, &StorageEntitlement{}, &DiaryLike{}, &DiaryFavorite{}, &SmokingWallet{}, &SmokingEntry{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LegacyTables names logical tables used by existing report queries.
|
// LegacyTables names logical tables used by existing report queries.
|
||||||
func LegacyTables() map[string]any {
|
func LegacyTables() map[string]any {
|
||||||
return map[string]any{"files": &File{}, "file_uploads": &FileUpload{}, "record_files": &RecordFile{}, "unbinding_file_cleanup": &UnbindingFileCleanup{}, "users": &User{}, "couples": &Couple{}, "invitations": &Invitation{}, "unbind_requests": &UnbindRequest{}, "invite_links": &InviteLink{}, "timeline": &Timeline{}, "albums": &Album{}, "photos": &Photo{}, "diaries": &Diary{}, "songs": &Song{}, "checkins": &Checkin{}, "checkin_logs": &CheckinLog{}, "smoking_accounts": &SmokingAccount{}, "smoking_logs": &SmokingLog{}, "period_records": &PeriodRecord{}, "period_daily_records": &PeriodDailyRecord{}, "period_sex_records": &PeriodSexRecord{}, "settings": &Setting{}, "diary_comments": &DiaryComment{}, "subscribe_templates": &SubscribeTemplate{}, "activity_log": &ActivityLog{}, "admins": &Admin{}, "user_tokens": &UserToken{}, "subscribe_message_logs": &SubscribeMessageLog{}, "notify_log": &NotifyLog{}, "pets": &Pet{}, "pet_dailies": &PetDaily{}, "pet_achievements": &PetAchievement{}, "pet_items": &PetItem{}, "pet_dynamics": &PetDynamic{}, "storage_orders": &StorageOrder{}, "diary_likes": &DiaryLike{}, "diary_favorites": &DiaryFavorite{}, "smoking_wallets": &SmokingWallet{}, "smoking_entries": &SmokingEntry{}}
|
return map[string]any{"files": &File{}, "file_uploads": &FileUpload{}, "record_files": &RecordFile{}, "unbinding_file_cleanup": &UnbindingFileCleanup{}, "users": &User{}, "couples": &Couple{}, "invitations": &Invitation{}, "unbind_requests": &UnbindRequest{}, "invite_links": &InviteLink{}, "timeline": &Timeline{}, "albums": &Album{}, "photos": &Photo{}, "diaries": &Diary{}, "songs": &Song{}, "checkins": &Checkin{}, "checkin_logs": &CheckinLog{}, "smoking_accounts": &SmokingAccount{}, "smoking_logs": &SmokingLog{}, "period_records": &PeriodRecord{}, "period_daily_records": &PeriodDailyRecord{}, "period_sex_records": &PeriodSexRecord{}, "settings": &Setting{}, "diary_comments": &DiaryComment{}, "subscribe_templates": &SubscribeTemplate{}, "activity_log": &ActivityLog{}, "admins": &Admin{}, "user_tokens": &UserToken{}, "subscribe_message_logs": &SubscribeMessageLog{}, "notify_log": &NotifyLog{}, "pets": &Pet{}, "pet_dailies": &PetDaily{}, "pet_achievements": &PetAchievement{}, "pet_items": &PetItem{}, "pet_dynamics": &PetDynamic{}, "storage_orders": &StorageOrder{}, "storage_plans": &StoragePlan{}, "storage_entitlements": &StorageEntitlement{}, "diary_likes": &DiaryLike{}, "diary_favorites": &DiaryFavorite{}, "smoking_wallets": &SmokingWallet{}, "smoking_entries": &SmokingEntry{}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
package notify
|
package notify
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"go.uber.org/zap"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) {
|
|||||||
func() {
|
func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
slog.Error("cron_notify panic", "err", r)
|
zap.S().Errorw("cron_notify panic", "err", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
Once(db, wx, false)
|
Once(db, wx, false)
|
||||||
@@ -91,7 +91,7 @@ func Once(db *store.DB, wx Sender, force bool) {
|
|||||||
args = append(args, maxBatch)
|
args = append(args, maxBatch)
|
||||||
activities, err := db.Query(query, args...)
|
activities, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("cron_notify 查询失败", "err", err)
|
zap.S().Errorw("cron_notify 查询失败", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
processed, success, fail, skip := 0, 0, 0, 0
|
processed, success, fail, skip := 0, 0, 0, 0
|
||||||
@@ -146,5 +146,5 @@ func Once(db *store.DB, wx Sender, force bool) {
|
|||||||
fail++
|
fail++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Info("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip)
|
zap.S().Infow("cron_notify 处理完成", "processed", processed, "success", success, "fail", fail, "skip", skip)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,32 +320,14 @@ func CheckFileQuota(db *gorm.DB, userId string, file model.File) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var vip int64
|
allowance, err := StorageSpace(db, userId)
|
||||||
if err := db.Model(&model.User{}).Where("id IN ? AND storage_vip_expire_at>NOW()", owners).Count(&vip).Error; err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if vip > 0 {
|
if allowance.Unlimited {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var user model.User
|
limitMB := allowance.QuotaMb
|
||||||
if err := db.First(&user, "id=?", userId).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
limitMB := user.StorageQuotaMb
|
|
||||||
if limitMB <= 0 {
|
|
||||||
var setting model.Setting
|
|
||||||
result := db.Where("key=?", "storage_free_mb").Find(&setting)
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
|
||||||
limitMB = 200
|
|
||||||
if result.RowsAffected > 0 && setting.Value != "" {
|
|
||||||
limitMB = ToInt(setting.Value)
|
|
||||||
if limitMB < 0 {
|
|
||||||
limitMB = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
used, err := UsedFileBytes(db, owners)
|
used, err := UsedFileBytes(db, owners)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"go.uber.org/zap"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -59,7 +59,7 @@ func (db *DB) finishUnbinding(cid string) error {
|
|||||||
func (db *DB) RunUnbindingWorker() {
|
func (db *DB) RunUnbindingWorker() {
|
||||||
run := func() {
|
run := func() {
|
||||||
if err := db.AdvanceUnbindings(); err != nil {
|
if err := db.AdvanceUnbindings(); err != nil {
|
||||||
slog.Error("解绑到期处理失败", "err", err)
|
zap.S().Errorw("解绑到期处理失败", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -63,11 +64,11 @@ func (db *DB) InitSchema() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte("past"), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
admin := model.Admin{Username: "admin", Password: "$2y$" + strings.TrimPrefix(string(hash), "$2a$")}
|
admin := model.Admin{Username: "past", Password: "$2y$" + strings.TrimPrefix(string(hash), "$2a$")}
|
||||||
if err := tx.Create(&admin).Error; err != nil {
|
if err := tx.Create(&admin).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -78,6 +79,10 @@ func (db *DB) InitSchema() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := initStoragePlans(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// A database sequence allocates display IDs atomically under registration load.
|
// A database sequence allocates display IDs atomically under registration load.
|
||||||
if err := tx.Exec(`CREATE SEQUENCE IF NOT EXISTS past_user_display_id START WITH 520001`).Error; err != nil {
|
if err := tx.Exec(`CREATE SEQUENCE IF NOT EXISTS past_user_display_id START WITH 520001`).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -85,3 +90,65 @@ func (db *DB) InitSchema() error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetDefaultAdmin preserves the original administrator ID when renaming it.
|
||||||
|
// It is an explicit maintenance action, never an automatic password reset at startup.
|
||||||
|
func (db *DB) ResetDefaultAdmin() error {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("past"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.ORM.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("SELECT pg_advisory_xact_lock(520001)").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var admin model.Admin
|
||||||
|
err := tx.Where("username = ?", "past").First(&admin).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
err = tx.Where("username = ?", "admin").First(&admin).Error
|
||||||
|
}
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return tx.Create(&model.Admin{Username: "past", Password: string(hash)}).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&admin).Updates(map[string]any{"username": "past", "password": string(hash)}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func initStoragePlans(tx *gorm.DB) error {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Unscoped().Model(&model.StoragePlan{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
for i, key := range []string{"month", "year"} {
|
||||||
|
months, price, name := int64(1), int64(500), "包月无限空间"
|
||||||
|
if key == "year" {
|
||||||
|
months, price, name = 12, 2990, "包年无限空间"
|
||||||
|
}
|
||||||
|
var setting model.Setting
|
||||||
|
if err := tx.Where("key=?", "storage_plan_"+key+"_price").Find(&setting).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v := ToInt(setting.Value); v > 0 {
|
||||||
|
price = v
|
||||||
|
}
|
||||||
|
if err := tx.Create(&model.StoragePlan{Key: key, Name: name, Months: months, PriceCents: price, Enabled: true, SortOrder: int64(i)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
if err := tx.Where("storage_vip_expire_at>NOW() AND storage_plan IN ?", []string{"", "month", "year"}).Where("NOT EXISTS (SELECT 1 FROM past_storage_entitlement e WHERE e.user_id=past_user.id AND e.deleted_at IS NULL)").Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
ent := model.StorageEntitlement{UserId: user.Id, SourceKey: "legacy:" + user.Id, PlanKey: user.StoragePlan, PlanName: "原无限空间会员", StartsAt: time.Now(), ExpiresAt: *user.StorageVipExpireAt}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"past/internal/model"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StorageAllowance struct {
|
||||||
|
PlanName string
|
||||||
|
FreeMb int64
|
||||||
|
QuotaMb int64
|
||||||
|
Unlimited bool
|
||||||
|
IsVip bool
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageSpace is shared with the current partner. Paid capacities use the
|
||||||
|
// largest active allowance, not their sum; each grant retains its own expiry.
|
||||||
|
func StorageSpace(db *gorm.DB, userId string) (StorageAllowance, error) {
|
||||||
|
out := StorageAllowance{FreeMb: 200, QuotaMb: 200}
|
||||||
|
var user model.User
|
||||||
|
if err := db.First(&user, "id=?", userId).Error; err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if user.StorageQuotaMb > 0 {
|
||||||
|
out.FreeMb = user.StorageQuotaMb
|
||||||
|
} else {
|
||||||
|
var setting model.Setting
|
||||||
|
r := db.Where("key=?", "storage_free_mb").Find(&setting)
|
||||||
|
if r.Error != nil {
|
||||||
|
return out, r.Error
|
||||||
|
}
|
||||||
|
if r.RowsAffected > 0 && setting.Value != "" {
|
||||||
|
out.FreeMb = max(0, ToInt(setting.Value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.QuotaMb = out.FreeMb
|
||||||
|
owners, err := UploadOwners(db, userId)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
var grants []model.StorageEntitlement
|
||||||
|
if err := db.Where("user_id IN ? AND starts_at<=NOW() AND expires_at>NOW()", owners).Find(&grants).Error; err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
apply := func(quota int64, expires time.Time, name string) {
|
||||||
|
out.IsVip = true
|
||||||
|
if quota == 0 {
|
||||||
|
if !out.Unlimited {
|
||||||
|
out.ExpiresAt = nil
|
||||||
|
}
|
||||||
|
out.Unlimited = true
|
||||||
|
} else if out.Unlimited {
|
||||||
|
return
|
||||||
|
} else if quota > out.QuotaMb {
|
||||||
|
out.QuotaMb = quota
|
||||||
|
out.ExpiresAt = nil
|
||||||
|
} else if quota < out.QuotaMb {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out.ExpiresAt == nil || expires.After(*out.ExpiresAt) {
|
||||||
|
e := expires
|
||||||
|
out.ExpiresAt = &e
|
||||||
|
out.PlanName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range grants {
|
||||||
|
apply(g.QuotaMb, g.ExpiresAt, g.PlanName)
|
||||||
|
}
|
||||||
|
// Compatibility for legacy membership fields written before this version.
|
||||||
|
var legacy []model.User
|
||||||
|
if err := db.Where("id IN ? AND storage_vip_expire_at>NOW() AND storage_plan IN ?", owners, []string{"", "month", "year"}).Where("NOT EXISTS (SELECT 1 FROM past_storage_entitlement e WHERE e.user_id=past_user.id AND e.deleted_at IS NULL)").Find(&legacy).Error; err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, u := range legacy {
|
||||||
|
apply(0, *u.StorageVipExpireAt, "原无限空间会员")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
package wechat
|
package wechat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"past/internal/store"
|
"past/internal/store"
|
||||||
"past/internal/testdb"
|
"past/internal/testdb"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type subscribeTransport func(*http.Request) (*http.Response, error)
|
type subscribeTransport func(*http.Request) (*http.Response, error)
|
||||||
@@ -80,9 +80,8 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) {
|
|||||||
steps = append(steps, meta...)
|
steps = append(steps, meta...)
|
||||||
steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM notify_log", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(0)}}}, testdb.Step{Match: "INSERT INTO notify_log"})
|
steps = append(steps, testdb.Step{Match: "SELECT COUNT(*) as cnt FROM notify_log", Columns: []string{"cnt"}, Rows: [][]driver.Value{{int64(0)}}}, testdb.Step{Match: "INSERT INTO notify_log"})
|
||||||
db, _ := testdb.Open(t, steps)
|
db, _ := testdb.Open(t, steps)
|
||||||
client := New("app", "secret", tt.template, t.TempDir(), store.WrapSQL(db))
|
client := New("app", "secret", tt.template, testRedis(t), "test:", store.WrapSQL(db))
|
||||||
os.MkdirAll(filepath.Dir(client.cacheFile()), 0755)
|
client.Redis.Set(context.Background(), client.tokenKey(), "cached-token", time.Hour)
|
||||||
os.WriteFile(client.cacheFile(), []byte("cached-token"), 0600)
|
|
||||||
calls := 0
|
calls := 0
|
||||||
client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) {
|
client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) {
|
||||||
calls++
|
calls++
|
||||||
@@ -118,7 +117,7 @@ func TestSubscribeWithoutActivityStillRecords(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
client := New("app", "secret", "template", t.TempDir(), store.WrapSQL(db))
|
client := New("app", "secret", "template", testRedis(t), "test:", store.WrapSQL(db))
|
||||||
if client.SendSubscribeMessage("2", "notify", nil, "", "", "") {
|
if client.SendSubscribeMessage("2", "notify", nil, "", "", "") {
|
||||||
t.Fatal("unsubscribed send succeeded")
|
t.Fatal("unsubscribed send succeeded")
|
||||||
}
|
}
|
||||||
@@ -126,7 +125,7 @@ func TestSubscribeWithoutActivityStillRecords(t *testing.T) {
|
|||||||
|
|
||||||
func TestSuccessfulEventIsNotSentAgain(t *testing.T) {
|
func TestSuccessfulEventIsNotSentAgain(t *testing.T) {
|
||||||
db, _ := testdb.Open(t, []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}, Rows: [][]driver.Value{{"success"}}}})
|
db, _ := testdb.Open(t, []testdb.Step{{Match: "SELECT status FROM notify_log", Columns: []string{"status"}, Rows: [][]driver.Value{{"success"}}}})
|
||||||
client := New("app", "secret", "template", t.TempDir(), store.WrapSQL(db))
|
client := New("app", "secret", "template", testRedis(t), "test:", store.WrapSQL(db))
|
||||||
client.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) { t.Fatal("duplicate sent to WeChat"); return nil, nil })
|
client.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) { t.Fatal("duplicate sent to WeChat"); return nil, nil })
|
||||||
if !client.SendSubscribeMessage("2", "notify", nil, "", "10", "1") {
|
if !client.SendSubscribeMessage("2", "notify", nil, "", "10", "1") {
|
||||||
t.Fatal("completed message must be treated as success")
|
t.Fatal("completed message must be treated as success")
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package wechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppID is part of the key; environments can use different key prefixes or DBs.
|
||||||
|
func (c *Client) tokenKey() string { return c.KeyPrefix + "wechat:{" + c.AppID + "}:access_token" }
|
||||||
|
|
||||||
|
var compareDelete = redis.NewScript(`if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`)
|
||||||
|
|
||||||
|
// AccessToken shares cached tokens and a refresh lock across processes.
|
||||||
|
// Redis failures never fall back to the old plaintext file or repeated refreshes.
|
||||||
|
func (c *Client) AccessToken() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.Redis == nil {
|
||||||
|
zap.L().Error("微信 token 缓存未初始化")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
key := c.tokenKey()
|
||||||
|
lockKey, owner := key+":lock", uuid.NewString()
|
||||||
|
for {
|
||||||
|
token, err := c.Redis.Get(ctx, key).Result()
|
||||||
|
if err == nil && token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
zap.L().Error("读取微信 token 缓存失败", zap.Error(err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
locked, err := c.Redis.SetNX(ctx, lockKey, owner, 30*time.Second).Result()
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("获取微信 token 刷新锁失败", zap.Error(err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
defer func() {
|
||||||
|
unlockCtx, done := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer done()
|
||||||
|
if err := compareDelete.Run(unlockCtx, c.Redis, []string{lockKey}, owner).Err(); err != nil {
|
||||||
|
zap.L().Warn("释放微信 token 刷新锁失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Another process may have filled the cache between our GET and SET NX.
|
||||||
|
token, err = c.Redis.Get(ctx, key).Result()
|
||||||
|
if err == nil && token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
zap.L().Error("读取微信 token 缓存失败", zap.Error(err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.fetchToken(ctx, key)
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(100 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
zap.L().Warn("等待微信 token 刷新超时")
|
||||||
|
return ""
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchToken(ctx context.Context, key string) string {
|
||||||
|
endpoint := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("微信 token 请求失败")
|
||||||
|
return ""
|
||||||
|
} // Do not log the URL containing AppSecret.
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var data struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
Errcode int `json:"errcode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || resp.StatusCode != http.StatusOK || data.Errcode != 0 || data.AccessToken == "" || data.ExpiresIn <= 0 {
|
||||||
|
zap.L().Error("微信 token 返回无效", zap.Int("http_status", resp.StatusCode), zap.Int("errcode", data.Errcode))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ttl := time.Duration(data.ExpiresIn) * time.Second
|
||||||
|
// Refresh early without making short-lived tokens immediately expire.
|
||||||
|
margin := min(ttl/10, 5*time.Minute)
|
||||||
|
ttl -= margin
|
||||||
|
if err := c.Redis.Set(ctx, key, data.AccessToken, ttl).Err(); err != nil {
|
||||||
|
zap.L().Error("写入微信 token 缓存失败", zap.Error(err))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return data.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate only the token rejected by WeChat, preserving a concurrent refresh.
|
||||||
|
func (c *Client) clearTokenCache(token string) {
|
||||||
|
if c.Redis == nil || token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := compareDelete.Run(ctx, c.Redis, []string{c.tokenKey()}, token).Err(); err != nil {
|
||||||
|
zap.L().Warn("清理失效微信 token 失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package wechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRedis(t *testing.T) *redis.Client {
|
||||||
|
t.Helper()
|
||||||
|
server := miniredis.RunT(t)
|
||||||
|
client := redis.NewClient(&redis.Options{Addr: server.Addr(), MaxRetries: -1})
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessTokenSharedRefreshExpiryAndInvalidation(t *testing.T) {
|
||||||
|
server := miniredis.RunT(t)
|
||||||
|
cache := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||||
|
defer cache.Close()
|
||||||
|
var requests atomic.Int32
|
||||||
|
transport := subscribeTransport(func(r *http.Request) (*http.Response, error) {
|
||||||
|
n := requests.Add(1)
|
||||||
|
if r.URL.Query().Get("appid") != "app" || r.URL.Query().Get("secret") != "secret" {
|
||||||
|
t.Error("missing credentials")
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"access_token":"token-%d","expires_in":7200}`, n)))}, nil
|
||||||
|
})
|
||||||
|
clients := make([]*Client, 12)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range clients {
|
||||||
|
clients[i] = New("app", "secret", "template", cache, "past-test:", nil)
|
||||||
|
clients[i].http.Transport = transport
|
||||||
|
wg.Add(1)
|
||||||
|
go func(c *Client) {
|
||||||
|
defer wg.Done()
|
||||||
|
if c.AccessToken() != "token-1" {
|
||||||
|
t.Error("concurrent refresh mismatch")
|
||||||
|
}
|
||||||
|
}(clients[i])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
c := clients[0]
|
||||||
|
if requests.Load() != 1 {
|
||||||
|
t.Fatalf("expected one refresh, got %d", requests.Load())
|
||||||
|
}
|
||||||
|
if ttl := server.TTL(c.tokenKey()); ttl != 6900*time.Second {
|
||||||
|
t.Fatalf("wrong token TTL: %s", ttl)
|
||||||
|
}
|
||||||
|
if server.Exists(c.tokenKey() + ":lock") {
|
||||||
|
t.Fatal("refresh lock leaked")
|
||||||
|
}
|
||||||
|
// A late rejection must not remove a newer token.
|
||||||
|
c.clearTokenCache("obsolete-token")
|
||||||
|
if c.AccessToken() != "token-1" {
|
||||||
|
t.Fatal("new token deleted by stale caller")
|
||||||
|
}
|
||||||
|
c.clearTokenCache("token-1")
|
||||||
|
if c.AccessToken() != "token-2" {
|
||||||
|
t.Fatal("invalid token was reused")
|
||||||
|
}
|
||||||
|
server.FastForward(6901 * time.Second)
|
||||||
|
if c.AccessToken() != "token-3" {
|
||||||
|
t.Fatal("expired token was reused")
|
||||||
|
}
|
||||||
|
other := New("different-app", "secret", "", cache, "past-test:", nil)
|
||||||
|
if c.tokenKey() == other.tokenKey() {
|
||||||
|
t.Fatal("AppIDs share a cache key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessTokenFailureDoesNotCacheOrFetchWithoutRedis(t *testing.T) {
|
||||||
|
for _, response := range []string{`{"errcode":40013}`, `{"access_token":"bad","expires_in":0}`, `not-json`} {
|
||||||
|
t.Run(response, func(t *testing.T) {
|
||||||
|
cache := testRedis(t)
|
||||||
|
c := New("app", "secret", "", cache, "test:", nil)
|
||||||
|
c.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response))}, nil
|
||||||
|
})
|
||||||
|
if c.AccessToken() != "" {
|
||||||
|
t.Fatal("invalid response accepted")
|
||||||
|
}
|
||||||
|
if n := cache.Exists(context.Background(), c.tokenKey(), c.tokenKey()+":lock").Val(); n != 0 {
|
||||||
|
t.Fatal("invalid token or lock retained")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cache := testRedis(t)
|
||||||
|
_ = cache.Close()
|
||||||
|
c := New("app", "secret", "", cache, "test:", nil)
|
||||||
|
c.http.Transport = subscribeTransport(func(*http.Request) (*http.Response, error) {
|
||||||
|
t.Error("WeChat contacted during Redis failure")
|
||||||
|
return nil, fmt.Errorf("unexpected")
|
||||||
|
})
|
||||||
|
if c.AccessToken() != "" {
|
||||||
|
t.Fatal("Redis failure ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -24,7 +25,8 @@ type Client struct {
|
|||||||
AppID string
|
AppID string
|
||||||
Secret string
|
Secret string
|
||||||
NotifyTemplateID string
|
NotifyTemplateID string
|
||||||
DataDir string // 用于 access_token.cache
|
Redis redis.UniversalClient
|
||||||
|
KeyPrefix string
|
||||||
DB *store.DB
|
DB *store.DB
|
||||||
|
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -32,9 +34,9 @@ type Client struct {
|
|||||||
sendLocks [64]sync.Mutex
|
sendLocks [64]sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(appID, secret, notifyTemplateID, dataDir string, db *store.DB) *Client {
|
func New(appID, secret, notifyTemplateID string, cache redis.UniversalClient, prefix string, db *store.DB) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
AppID: appID, Secret: secret, NotifyTemplateID: notifyTemplateID, DataDir: dataDir, DB: db,
|
AppID: appID, Secret: secret, NotifyTemplateID: notifyTemplateID, Redis: cache, KeyPrefix: prefix, DB: db,
|
||||||
http: &http.Client{Timeout: 10 * time.Second},
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,41 +46,6 @@ func (c *Client) Templates() map[string]string {
|
|||||||
return map[string]string{"notify": c.NotifyTemplateID}
|
return map[string]string{"notify": c.NotifyTemplateID}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) cacheFile() string { return filepath.Join(c.DataDir, "data", "access_token.cache") }
|
|
||||||
|
|
||||||
// AccessToken 获取 access_token;文件缓存 115 分钟(按修改时间判断),与 PHP 一致。
|
|
||||||
func (c *Client) AccessToken() string {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
cf := c.cacheFile()
|
|
||||||
if st, err := os.Stat(cf); err == nil && time.Since(st.ModTime()) < 6900*time.Second {
|
|
||||||
if b, err := os.ReadFile(cf); err == nil && len(bytes.TrimSpace(b)) > 0 {
|
|
||||||
return string(bytes.TrimSpace(b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
u := "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + url.QueryEscape(c.AppID) + "&secret=" + url.QueryEscape(c.Secret)
|
|
||||||
resp, err := c.http.Get(u)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var data struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.AccessToken == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
os.MkdirAll(filepath.Dir(cf), 0755)
|
|
||||||
os.WriteFile(cf, []byte(data.AccessToken), 0644)
|
|
||||||
return data.AccessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) clearTokenCache() {
|
|
||||||
c.mu.Lock()
|
|
||||||
os.Remove(c.cacheFile())
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// postJSON 对应 wechatPost:非 200 返回 nil
|
// postJSON 对应 wechatPost:非 200 返回 nil
|
||||||
func (c *Client) postJSON(u string, body []byte, timeout time.Duration) map[string]any {
|
func (c *Client) postJSON(u string, body []byte, timeout time.Duration) map[string]any {
|
||||||
cl := &http.Client{Timeout: timeout}
|
cl := &http.Client{Timeout: timeout}
|
||||||
@@ -197,7 +164,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data
|
|||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
previous, err := c.DB.QueryOne("SELECT status FROM notify_log WHERE activity_id = ? AND to_user_id = ? AND status='success' UNION ALL SELECT status FROM subscribe_message_logs WHERE activity_id = ? AND to_user_id = ? AND status='success' LIMIT 1", activityID, toUserID, activityID, toUserID)
|
previous, err := c.DB.QueryOne("SELECT status FROM notify_log WHERE activity_id = ? AND to_user_id = ? AND status='success' UNION ALL SELECT status FROM subscribe_message_logs WHERE activity_id = ? AND to_user_id = ? AND status='success' LIMIT 1", activityID, toUserID, activityID, toUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("读取通知去重状态失败", "err", err)
|
zap.S().Errorw("读取通知去重状态失败", "err", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if previous != nil && previous.Str("status") == "success" {
|
if previous != nil && previous.Str("status") == "success" {
|
||||||
@@ -261,7 +228,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data
|
|||||||
if tok == "" {
|
if tok == "" {
|
||||||
lastError = "获取access_token失败"
|
lastError = "获取access_token失败"
|
||||||
if attempt < 2 {
|
if attempt < 2 {
|
||||||
c.clearTokenCache()
|
c.clearTokenCache(tok)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -275,7 +242,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data
|
|||||||
lastError += ": " + uerr.Err.Error()
|
lastError += ": " + uerr.Err.Error()
|
||||||
}
|
}
|
||||||
if attempt < 2 {
|
if attempt < 2 {
|
||||||
c.clearTokenCache()
|
c.clearTokenCache(tok)
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -285,7 +252,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode)
|
lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode)
|
||||||
if attempt < 2 {
|
if attempt < 2 {
|
||||||
c.clearTokenCache()
|
c.clearTokenCache(tok)
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -306,7 +273,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data
|
|||||||
}
|
}
|
||||||
lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec)
|
lastError = fmt.Sprintf("微信错误: %s (code: %d)", store.ToStr(result["errmsg"]), ec)
|
||||||
if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 {
|
if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 {
|
||||||
c.clearTokenCache()
|
c.clearTokenCache(tok)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 即使微信返回“用户拒收/一次性订阅次数用尽”(40003/43101),也保留 subscribe_templates 记录:
|
// 即使微信返回“用户拒收/一次性订阅次数用尽”(40003/43101),也保留 subscribe_templates 记录:
|
||||||
@@ -362,7 +329,7 @@ func (c *Client) logSubscribeAttempt(activityID, toUserID, userID string, templa
|
|||||||
_, err := c.DB.Exec(`INSERT INTO subscribe_message_logs (activity_id, couple_id, user_id, to_user_id, to_openid, action_type, message_title, message_content, template_type, template_id, page, status, error_code, http_status, error_msg, attempts, duration_ms, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
_, err := c.DB.Exec(`INSERT INTO subscribe_message_logs (activity_id, couple_id, user_id, to_user_id, to_openid, action_type, message_title, message_content, template_type, template_id, page, status, error_code, http_status, error_msg, attempts, duration_ms, sent_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
activityID, coupleID, userID, toUserID, openid, action, data["thing1"], data["thing2"], templateType, templateID, page, status, errorCode, httpStatus, errorMsg, attempts, time.Since(started).Milliseconds(), sentAt)
|
activityID, coupleID, userID, toUserID, openid, action, data["thing1"], data["thing2"], templateType, templateID, page, status, errorCode, httpStatus, errorMsg, attempts, time.Since(started).Milliseconds(), sentAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("订阅消息发送日志写入失败", "activity_id", activityID, "to_user_id", toUserID, "err", err)
|
zap.S().Errorw("订阅消息发送日志写入失败", "activity_id", activityID, "to_user_id", toUserID, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +362,7 @@ func (c *Client) LogNotifyResult(activityID, toUserID, userID string, templateTy
|
|||||||
activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now)
|
activityID, coupleID, userID, actionType, templateType, toUserID, status, errorMsg, now)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("logNotifyResult error", "err", err)
|
zap.S().Errorw("logNotifyResult error", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Package wxpay integrates direct-merchant API v3 mini-program payment.
|
||||||
|
package wxpay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||||
|
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||||
|
"past/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PrepayOrder struct {
|
||||||
|
Number, Description, OpenID, Attach string
|
||||||
|
Amount int64
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
type Gateway interface {
|
||||||
|
Prepay(context.Context, PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error)
|
||||||
|
Query(context.Context, string) (*payments.Transaction, error)
|
||||||
|
ParseNotify(context.Context, *http.Request) (*payments.Transaction, error)
|
||||||
|
}
|
||||||
|
type Client struct {
|
||||||
|
cfg config.WechatPayConfig
|
||||||
|
service jsapi.JsapiApiService
|
||||||
|
handler *notify.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.WechatPayConfig) (*Client, error) {
|
||||||
|
if cfg.AppID == "" || cfg.MchID == "" || cfg.CertificateSerial == "" || cfg.PublicKeyID == "" || len(cfg.APIv3Key) != 32 {
|
||||||
|
return nil, errors.New("微信支付 AppID、商户号、证书序列号、公钥 ID 及 32 字节 APIv3 密钥必须配置")
|
||||||
|
}
|
||||||
|
if err := cfg.ValidateNotifyURL(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
privateKey, err := utils.LoadPrivateKeyWithPath(cfg.PrivateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("加载微信支付商户私钥: %w", err)
|
||||||
|
}
|
||||||
|
publicKey, err := utils.LoadPublicKeyWithPath(cfg.PublicKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("加载微信支付公钥: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateCertificate(cfg, privateKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client, err := core.NewClient(context.Background(), option.WithWechatPayPublicKeyAuthCipher(cfg.MchID, cfg.CertificateSerial, privateKey, cfg.PublicKeyID, publicKey), option.WithHTTPClient(&http.Client{Timeout: 15 * time.Second}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handler, err := notify.NewRSANotifyHandler(cfg.APIv3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(cfg.PublicKeyID, *publicKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, service: jsapi.JsapiApiService{Client: client}, handler: handler}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCertificate(cfg config.WechatPayConfig, key *rsa.PrivateKey) error {
|
||||||
|
if cfg.CertificatePath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(cfg.CertificatePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return errors.New("无效的商户证书")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||||
|
if !ok || pub.N.Cmp(key.N) != 0 || pub.E != key.E {
|
||||||
|
return errors.New("商户证书与私钥不匹配")
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%X", cert.SerialNumber) != cfg.CertificateSerial || cert.Subject.CommonName != cfg.MchID {
|
||||||
|
return errors.New("商户证书序列号或商户号与配置不匹配")
|
||||||
|
}
|
||||||
|
if time.Now().Before(cert.NotBefore) || time.Now().After(cert.NotAfter) {
|
||||||
|
return errors.New("商户证书不在有效期内")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c *Client) Prepay(ctx context.Context, o PrepayOrder) (*jsapi.PrepayWithRequestPaymentResponse, error) {
|
||||||
|
res, _, err := c.service.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{Appid: core.String(c.cfg.AppID), Mchid: core.String(c.cfg.MchID), Description: core.String(o.Description), OutTradeNo: core.String(o.Number), NotifyUrl: core.String(c.cfg.NotifyURL), Attach: core.String(o.Attach), TimeExpire: core.Time(o.ExpiresAt), Amount: &jsapi.Amount{Total: core.Int64(o.Amount), Currency: core.String("CNY")}, Payer: &jsapi.Payer{Openid: core.String(o.OpenID)}})
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
func (c *Client) Query(ctx context.Context, number string) (*payments.Transaction, error) {
|
||||||
|
res, _, err := c.service.QueryOrderByOutTradeNo(ctx, jsapi.QueryOrderByOutTradeNoRequest{Mchid: core.String(c.cfg.MchID), OutTradeNo: core.String(number)})
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
func (c *Client) ParseNotify(ctx context.Context, r *http.Request) (*payments.Transaction, error) {
|
||||||
|
tx := new(payments.Transaction)
|
||||||
|
req, err := c.handler.ParseNotifyRequest(ctx, r, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.EventType != "TRANSACTION.SUCCESS" || req.Resource == nil || req.Resource.OriginalType != "transaction" {
|
||||||
|
return nil, errors.New("不支持的微信支付通知类型")
|
||||||
|
}
|
||||||
|
return tx, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package wxpay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"past/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTrip func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
func sign(t *testing.T, key *rsa.PrivateKey, msg string) string {
|
||||||
|
t.Helper()
|
||||||
|
hash := sha256.Sum256([]byte(msg))
|
||||||
|
s, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(s)
|
||||||
|
}
|
||||||
|
func fixture(t *testing.T) (config.WechatPayConfig, *rsa.PrivateKey, *rsa.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
merchant, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
platform, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.WechatPayConfig{AppID: "wx-test", MchID: "1234567890", CertificateSerial: "1234", APIv3Key: "0123456789abcdef0123456789abcdef", PublicKeyID: "PUB_KEY_ID_TEST", NotifyURL: "https://past.example/api/wechatPayNotify", PrivateKeyPath: filepath.Join(dir, "key.pem"), PublicKeyPath: filepath.Join(dir, "public.pem"), CertificatePath: filepath.Join(dir, "cert.pem")}
|
||||||
|
cert := &x509.Certificate{SerialNumber: big.NewInt(0x1234), Subject: pkix.Name{CommonName: cfg.MchID}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, cert, cert, &merchant.PublicKey, merchant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pub, err := x509.MarshalPKIXPublicKey(&platform.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
privateDER, err := x509.MarshalPKCS8PrivateKey(merchant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for p, b := range map[string]*pem.Block{cfg.PrivateKeyPath: {Type: "PRIVATE KEY", Bytes: privateDER}, cfg.CertificatePath: {Type: "CERTIFICATE", Bytes: der}, cfg.PublicKeyPath: {Type: "PUBLIC KEY", Bytes: pub}} {
|
||||||
|
if err := os.WriteFile(p, pem.EncodeToMemory(b), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, merchant, platform
|
||||||
|
}
|
||||||
|
func headers(t *testing.T, cfg config.WechatPayConfig, key *rsa.PrivateKey, body string) http.Header {
|
||||||
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
nonce := "test-nonce"
|
||||||
|
return http.Header{"Wechatpay-Timestamp": {ts}, "Wechatpay-Nonce": {nonce}, "Wechatpay-Serial": {cfg.PublicKeyID}, "Wechatpay-Signature": {sign(t, key, ts+"\n"+nonce+"\n"+body+"\n")}}
|
||||||
|
}
|
||||||
|
func TestPrepayQueryAndSignatures(t *testing.T) {
|
||||||
|
cfg, merchant, platform := fixture(t)
|
||||||
|
old := http.DefaultTransport
|
||||||
|
defer func() { http.DefaultTransport = old }()
|
||||||
|
calls := 0
|
||||||
|
http.DefaultTransport = roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if r.URL.Host != "api.mch.weixin.qq.com" {
|
||||||
|
t.Fatalf("unexpected host %s", r.URL.Host)
|
||||||
|
}
|
||||||
|
body := ""
|
||||||
|
if r.Body != nil {
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
body = string(b)
|
||||||
|
}
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
parts := map[string]string{}
|
||||||
|
for _, m := range regexp.MustCompile(`(\w+)="([^"]+)"`).FindAllStringSubmatch(auth, -1) {
|
||||||
|
parts[m[1]] = m[2]
|
||||||
|
}
|
||||||
|
msg := r.Method + "\n" + r.URL.RequestURI() + "\n" + parts["timestamp"] + "\n" + parts["nonce_str"] + "\n" + body + "\n"
|
||||||
|
hash := sha256.Sum256([]byte(msg))
|
||||||
|
sig, _ := base64.StdEncoding.DecodeString(parts["signature"])
|
||||||
|
if parts["mchid"] != cfg.MchID || parts["serial_no"] != cfg.CertificateSerial || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, hash[:], sig) != nil {
|
||||||
|
t.Fatal("merchant request signature invalid")
|
||||||
|
}
|
||||||
|
response := `{"prepay_id":"test-prepay"}`
|
||||||
|
if r.Method == "POST" {
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(body), &data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if data["appid"] != cfg.AppID || data["mchid"] != cfg.MchID || data["out_trade_no"] != "P12345678" || data["amount"].(map[string]any)["total"] != float64(500) || data["payer"].(map[string]any)["openid"] != "openid" {
|
||||||
|
t.Fatalf("incorrect prepay payload: %s", body)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response = `{"out_trade_no":"P12345678","trade_state":"NOTPAY"}`
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Header: headers(t, cfg, platform, response), Body: io.NopCloser(strings.NewReader(response))}, nil
|
||||||
|
})
|
||||||
|
client, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := client.Prepay(context.Background(), PrepayOrder{Number: "P12345678", Description: "space", OpenID: "openid", Amount: 500, ExpiresAt: time.Now().Add(30 * time.Minute)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msg := cfg.AppID + "\n" + *p.TimeStamp + "\n" + *p.NonceStr + "\n" + *p.Package + "\n"
|
||||||
|
hash := sha256.Sum256([]byte(msg))
|
||||||
|
sig, _ := base64.StdEncoding.DecodeString(*p.PaySign)
|
||||||
|
if *p.SignType != "RSA" || *p.Package != "prepay_id=test-prepay" || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, hash[:], sig) != nil {
|
||||||
|
t.Fatal("invalid requestPayment signature")
|
||||||
|
}
|
||||||
|
q, err := client.Query(context.Background(), "P12345678")
|
||||||
|
if err != nil || *q.TradeState != "NOTPAY" || calls != 2 {
|
||||||
|
t.Fatalf("query failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestNotifyVerifiesSignatureAndDecrypts(t *testing.T) {
|
||||||
|
cfg, _, platform := fixture(t)
|
||||||
|
client, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, _ := aes.NewCipher([]byte(cfg.APIv3Key))
|
||||||
|
gcm, _ := cipher.NewGCM(block)
|
||||||
|
nonce := "123456789012"
|
||||||
|
ad := "transaction"
|
||||||
|
plaintext := []byte(`{"out_trade_no":"P12345678","trade_state":"SUCCESS","amount":{"total":500,"currency":"CNY"}}`)
|
||||||
|
encrypted := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plaintext, []byte(ad)))
|
||||||
|
raw, _ := json.Marshal(map[string]any{"id": "notify-1", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": encrypted, "nonce": nonce, "associated_data": ad, "original_type": "transaction"}})
|
||||||
|
for _, mode := range []string{"valid", "signature", "ciphertext", "serial", "missing"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
body := string(raw)
|
||||||
|
if mode == "ciphertext" {
|
||||||
|
body = strings.Replace(body, encrypted, encrypted[:len(encrypted)-4]+"AAAA", 1)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("POST", "/api/wechatPayNotify", bytes.NewBufferString(body))
|
||||||
|
r.Header = headers(t, cfg, platform, body)
|
||||||
|
switch mode {
|
||||||
|
case "signature":
|
||||||
|
r.Header.Set("Wechatpay-Signature", sign(t, platform, "wrong"))
|
||||||
|
case "serial":
|
||||||
|
r.Header.Set("Wechatpay-Serial", "PUB_KEY_ID_OTHER")
|
||||||
|
case "missing":
|
||||||
|
r.Header.Del("Wechatpay-Signature")
|
||||||
|
}
|
||||||
|
tx, err := client.ParseNotify(context.Background(), r)
|
||||||
|
if mode == "valid" {
|
||||||
|
if err != nil || *tx.OutTradeNo != "P12345678" || *tx.Amount.Total != 500 {
|
||||||
|
t.Fatalf("notify parsing: %v", err)
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
t.Fatal("invalid notify accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
optional := cfg
|
||||||
|
optional.CertificatePath = ""
|
||||||
|
if _, err := New(optional); err != nil {
|
||||||
|
t.Fatalf("optional certificate: %v", err)
|
||||||
|
}
|
||||||
|
cfg.CertificateSerial = "BAD"
|
||||||
|
if _, err := New(cfg); err == nil {
|
||||||
|
t.Fatal("wrong merchant certificate accepted")
|
||||||
|
}
|
||||||
|
cfg.CertificateSerial = "1234"
|
||||||
|
cfg.NotifyURL = "http://localhost/callback"
|
||||||
|
if _, err := New(cfg); err == nil {
|
||||||
|
t.Fatal("insecure callback accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export interface StoragePlan {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
quota_mb: number
|
||||||
|
months: number
|
||||||
|
price_cents: number
|
||||||
|
enabled: boolean
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoragePlanEditor({plans,onChange,disabled}:{plans:StoragePlan[];onChange:(p:StoragePlan[])=>void;disabled:boolean}) {
|
||||||
|
const update=(index:number,patch:Partial<StoragePlan>)=>onChange(plans.map((p,i)=>i===index?{...p,...patch}:p))
|
||||||
|
return <div className="mt-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="font-semibold">容量套餐({plans.length} 档)</h3><Button variant="outline" disabled={disabled || plans.length>=100} onClick={()=>onChange([...plans,{key:crypto.randomUUID(),name:"",quota_mb:1024,months:1,price_cents:500,enabled:false,sort_order:plans.length}])}>新增套餐</Button></div>
|
||||||
|
<p className="text-sm text-muted-foreground">容量为总可用容量,0 表示无限;1024MB = 1GB。不同档位取当前有效的最大容量,不累加;同容量续费顺延,不同容量分别到期。变更和下架不影响已购买权益。</p>
|
||||||
|
{plans.map((p,i)=><div key={p.key} className="space-y-3 rounded-xl border p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3"><span className="text-sm font-medium">套餐 {i+1}</span><div className="flex items-center gap-4"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={p.enabled} disabled={disabled} onChange={e=>update(i,{enabled:e.target.checked})}/>上架</label><Button size="sm" variant="outline" disabled={disabled} onClick={()=>onChange(plans.filter((_,n)=>n!==i))}>移除</Button></div></div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<div className="space-y-1"><Label htmlFor={'name-'+p.key}>套餐名称</Label><Input id={'name-'+p.key} maxLength={60} value={p.name} disabled={disabled} onChange={e=>update(i,{name:e.target.value})} placeholder="例如:5GB 季度套餐"/></div>
|
||||||
|
<div className="space-y-1"><Label htmlFor={'quota-'+p.key}>容量(MB,0 为无限)</Label><Input id={'quota-'+p.key} type="number" min={0} max={104857600} step={1} value={p.quota_mb} disabled={disabled} onChange={e=>update(i,{quota_mb:Number(e.target.value)})}/></div>
|
||||||
|
<div className="space-y-1"><Label htmlFor={'months-'+p.key}>有效期(月)</Label><Input id={'months-'+p.key} type="number" min={1} max={120} step={1} value={p.months} disabled={disabled} onChange={e=>update(i,{months:Number(e.target.value)})}/></div>
|
||||||
|
<div className="space-y-1"><Label htmlFor={'price-'+p.key}>价格(元)</Label><Input id={'price-'+p.key} type="number" min={0.01} max={1000000} step={0.01} value={p.price_cents/100} disabled={disabled} onChange={e=>update(i,{price_cents:Math.round(Number(e.target.value)*100)})}/></div>
|
||||||
|
<div className="space-y-1"><Label htmlFor={'sort-'+p.key}>排序(小的在前)</Label><Input id={'sort-'+p.key} type="number" min={0} max={1000000} step={1} value={p.sort_order} disabled={disabled} onChange={e=>update(i,{sort_order:Number(e.target.value)})}/></div>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
{plans.length===0 && <p className="py-6 text-center text-muted-foreground">暂无套餐,保存后小程序将停止展示可购买套餐。</p>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ function mapKeys(value: any, name: (key: string) => string): any {
|
|||||||
const toWire = (value: unknown) => mapKeys(value, camelName)
|
const toWire = (value: unknown) => mapKeys(value, camelName)
|
||||||
const fromWire = (value: unknown) => mapKeys(value, snakeName)
|
const fromWire = (value: unknown) => mapKeys(value, snakeName)
|
||||||
|
|
||||||
type Params = Record<string, string | number | boolean | null | undefined>
|
type Params = Record<string, unknown>
|
||||||
|
|
||||||
export async function api<T = any>(action: string, data?: Params | null, method: "GET" | "POST" = "POST"): Promise<T> {
|
export async function api<T = any>(action: string, data?: Params | null, method: "GET" | "POST" = "POST"): Promise<T> {
|
||||||
let url = `${API_BASE}/${encodeURIComponent(camelName(action))}`
|
let url = `${API_BASE}/${encodeURIComponent(camelName(action))}`
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function SettingsPage() {
|
|||||||
setUsername("")
|
setUsername("")
|
||||||
})
|
})
|
||||||
const changePassword = wrap(async () => {
|
const changePassword = wrap(async () => {
|
||||||
if (newPw.length < 6) return toast.error("新密码至少6位")
|
if (newPw.length < 4) return toast.error("新密码至少4位")
|
||||||
await api("adminChangePassword", { old_password: oldPw, new_password: newPw })
|
await api("adminChangePassword", { old_password: oldPw, new_password: newPw })
|
||||||
toast.success("密码已更新")
|
toast.success("密码已更新")
|
||||||
setOldPw(""); setNewPw("")
|
setOldPw(""); setNewPw("")
|
||||||
@@ -54,7 +54,7 @@ export default function SettingsPage() {
|
|||||||
<CardHeader><CardTitle className="text-base">修改密码</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">修改密码</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="space-y-1"><Label>原密码</Label><Input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} /></div>
|
<div className="space-y-1"><Label>原密码</Label><Input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} /></div>
|
||||||
<div className="space-y-1"><Label>新密码</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="6位以上" /></div>
|
<div className="space-y-1"><Label>新密码</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="4位以上" /></div>
|
||||||
<Button onClick={changePassword}>保存</Button>
|
<Button onClick={changePassword}>保存</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { StoragePlanEditor, type StoragePlan } from "@/components/StoragePlanEditor"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Search, X, Crown } from "lucide-react"
|
import { Search, X, Crown } from "lucide-react"
|
||||||
@@ -37,6 +38,8 @@ interface StorageUser {
|
|||||||
used_bytes: number
|
used_bytes: number
|
||||||
free_mb: number
|
free_mb: number
|
||||||
storage_quota_mb: number
|
storage_quota_mb: number
|
||||||
|
quota_mb:number
|
||||||
|
is_unlimited:boolean
|
||||||
storage_vip: boolean
|
storage_vip: boolean
|
||||||
storage_plan: string
|
storage_plan: string
|
||||||
plan_label: string
|
plan_label: string
|
||||||
@@ -59,6 +62,7 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on
|
|||||||
const [quota, setQuota] = useState("")
|
const [quota, setQuota] = useState("")
|
||||||
const [plan, setPlan] = useState("free")
|
const [plan, setPlan] = useState("free")
|
||||||
const [expire, setExpire] = useState("")
|
const [expire, setExpire] = useState("")
|
||||||
|
const catalog=useQuery({queryKey:["storage_plan_catalog"],queryFn:()=>api<StorageSettingsResp>("adminStorageSettings",null,"GET"),enabled:!!user})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
@@ -109,13 +113,13 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>会员套餐</Label>
|
<Label>容量套餐</Label>
|
||||||
<Select value={plan} onValueChange={setPlan}>
|
<Select value={plan} onValueChange={setPlan}>
|
||||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">免费用户(无会员)</SelectItem>
|
<SelectItem value="free">免费用户(无会员)</SelectItem>
|
||||||
<SelectItem value="month">包月无限空间</SelectItem>
|
{(catalog.data?.plans || []).map(p=><SelectItem key={p.key} value={p.key}>{p.name} · {p.quota_mb===0?"无限空间":`${p.quota_mb} MB`} / {p.months}个月</SelectItem>)}
|
||||||
<SelectItem value="year">包年无限空间</SelectItem>
|
{plan!=="free" && !(catalog.data?.plans || []).some(p=>p.key===plan) && <SelectItem value={plan}>原套餐(已下架)</SelectItem>}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +128,7 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>到期时间(留空自动计算)</Label>
|
<Label>到期时间(留空自动计算)</Label>
|
||||||
<Input type="datetime-local" value={expire} onChange={(e) => setExpire(e.target.value)} />
|
<Input type="datetime-local" value={expire} onChange={(e) => setExpire(e.target.value)} />
|
||||||
<p className="text-xs text-muted-foreground">包月自动加 1 个月、包年自动加 12 个月;也可手动指定到期时间。</p>
|
<p className="text-xs text-muted-foreground">按所选套餐有效月数计算;手动设置会替换该用户原有套餐权益。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -140,16 +144,12 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface StorageSettingsResp {
|
interface StorageSettingsResp {
|
||||||
|
plans:StoragePlan[]
|
||||||
|
revision:number
|
||||||
|
pay_provider: string
|
||||||
|
wechat_pay_ready: boolean
|
||||||
|
wechat_pay_mch_id: string
|
||||||
free_mb: number
|
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 {
|
interface StorageOrder {
|
||||||
@@ -161,10 +161,8 @@ interface StorageOrder {
|
|||||||
out_trade_no: string
|
out_trade_no: string
|
||||||
plan_key: string
|
plan_key: string
|
||||||
plan_label: string
|
plan_label: string
|
||||||
product_id: string
|
|
||||||
amount_cents: number
|
amount_cents: number
|
||||||
env: number
|
pay_provider: string
|
||||||
env_label: string
|
|
||||||
status: string
|
status: string
|
||||||
wx_order_id: string
|
wx_order_id: string
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -185,6 +183,7 @@ function orderStatusBadge(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StoragePage() {
|
export default function StoragePage() {
|
||||||
|
const qc=useQueryClient()
|
||||||
// 用户管理
|
// 用户管理
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
@@ -229,58 +228,42 @@ export default function StoragePage() {
|
|||||||
|
|
||||||
// 收费设置
|
// 收费设置
|
||||||
const [freeMb, setFreeMb] = useState("200")
|
const [freeMb, setFreeMb] = useState("200")
|
||||||
const [monthPrice, setMonthPrice] = useState("5")
|
const [plans,setPlans]=useState<StoragePlan[]>([])
|
||||||
const [monthProductId, setMonthProductId] = useState("")
|
const [revision,setRevision]=useState(0)
|
||||||
const [yearPrice, setYearPrice] = useState("29.9")
|
const [savingFee,setSavingFee]=useState(false)
|
||||||
const [yearProductId, setYearProductId] = useState("")
|
const [wechatReady, setWechatReady] = useState(false)
|
||||||
const [payMode, setPayMode] = useState("short_series_goods")
|
const [wechatMch, setWechatMch] = useState("")
|
||||||
const [payEnv, setPayEnv] = useState(1)
|
|
||||||
const [payOfferId, setPayOfferId] = useState("")
|
|
||||||
const [payAppKey, setPayAppKey] = useState("")
|
|
||||||
const [paySandboxKey, setPaySandboxKey] = useState("")
|
|
||||||
const [feeLoading, setFeeLoading] = useState(true)
|
const [feeLoading, setFeeLoading] = useState(true)
|
||||||
|
const [feeLoaded,setFeeLoaded]=useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<StorageSettingsResp>("adminStorageSettings", null, "GET")
|
api<StorageSettingsResp>("adminStorageSettings", null, "GET")
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
|
setWechatReady(d.wechat_pay_ready)
|
||||||
|
setWechatMch(d.wechat_pay_mch_id || "")
|
||||||
setFreeMb(String(d.free_mb ?? 200))
|
setFreeMb(String(d.free_mb ?? 200))
|
||||||
setMonthPrice((d.month_price_cents / 100).toString())
|
setPlans(d.plans)
|
||||||
setMonthProductId(d.month_product_id || "")
|
setRevision(d.revision)
|
||||||
setYearPrice((d.year_price_cents / 100).toString())
|
setFeeLoaded(true)
|
||||||
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))
|
.catch((e) => toast.error((e as Error).message))
|
||||||
.finally(() => setFeeLoading(false))
|
.finally(() => setFeeLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function saveFee() {
|
async function saveFee() {
|
||||||
const monthCents = Math.round(parseFloat(monthPrice || "0") * 100)
|
const freeMbNum=Number(freeMb)
|
||||||
const yearCents = Math.round(parseFloat(yearPrice || "0") * 100)
|
if (!Number.isInteger(freeMbNum) || freeMbNum<0 || freeMbNum>104857600) return toast.error("免费容量无效")
|
||||||
const freeMbNum = parseInt(freeMb || "0", 10)
|
for (const p of plans) {
|
||||||
if (freeMbNum <= 0) return toast.error("免费额度需大于0")
|
if (!p.name.trim() || p.name.trim().length>60 || !Number.isInteger(p.quota_mb) || p.quota_mb<0 || p.quota_mb>104857600 || !Number.isInteger(p.months) || p.months<1 || p.months>120 || !Number.isInteger(p.price_cents) || p.price_cents<1 || p.price_cents>100000000 || !Number.isInteger(p.sort_order) || p.sort_order<0 || p.sort_order>1000000) return toast.error("请填写有效的套餐名称、容量、月数、价格和排序")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
setSavingFee(true)
|
||||||
|
try {
|
||||||
|
await api("adminStorageSettings",{free_mb:freeMbNum,plans,revision})
|
||||||
|
const saved=await api<StorageSettingsResp>("adminStorageSettings",null,"GET")
|
||||||
|
setPlans(saved.plans);setRevision(saved.revision)
|
||||||
|
qc.invalidateQueries({queryKey:["storage_plan_catalog"]})
|
||||||
|
toast.success("存储收费配置已保存")
|
||||||
|
} catch(e) {toast.error((e as Error).message)} finally {setSavingFee(false)}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -371,7 +354,7 @@ export default function StoragePage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-sm">{formatBytes(u.used_bytes)}</TableCell>
|
<TableCell className="whitespace-nowrap text-sm">{formatBytes(u.used_bytes)}</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-sm">
|
<TableCell className="whitespace-nowrap text-sm">
|
||||||
{u.storage_vip ? <span className="text-muted-foreground">♾️ 无限</span> : `${u.free_mb} MB`}
|
{u.is_unlimited ? <span className="text-muted-foreground">♾️ 无限</span> : formatBytes(u.quota_mb*1024*1024)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{u.storage_vip ? <Badge variant="default">{u.plan_label || "会员"}</Badge> : <Badge variant="outline">免费用户</Badge>}
|
{u.storage_vip ? <Badge variant="default">{u.plan_label || "会员"}</Badge> : <Badge variant="outline">免费用户</Badge>}
|
||||||
@@ -408,42 +391,15 @@ export default function StoragePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<Label>微信支付</Label>
|
||||||
<Label>支付环境</Label>
|
<p className="text-sm">{wechatReady ? "已启用小程序支付" : "支付服务尚未就绪"}</p>
|
||||||
<Select value={String(payEnv)} onValueChange={(v) => setPayEnv(Number(v))}>
|
<p className="text-sm text-muted-foreground">商户号:{wechatMch || "未配置"}</p>
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
<StoragePlanEditor plans={plans} onChange={setPlans} disabled={!feeLoaded || feeLoading || savingFee}/>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<Button onClick={saveFee} disabled={feeLoading}>保存收费设置</Button>
|
<Button onClick={saveFee} disabled={!feeLoaded || feeLoading || savingFee}>{savingFee ? "保存中…" : "保存收费设置"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -486,7 +442,7 @@ export default function StoragePage() {
|
|||||||
<TableHead>订单号</TableHead>
|
<TableHead>订单号</TableHead>
|
||||||
<TableHead>套餐</TableHead>
|
<TableHead>套餐</TableHead>
|
||||||
<TableHead>金额</TableHead>
|
<TableHead>金额</TableHead>
|
||||||
<TableHead>环境</TableHead>
|
<TableHead>支付方式</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>微信订单号</TableHead>
|
<TableHead>微信订单号</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
<TableHead>创建时间</TableHead>
|
||||||
@@ -507,7 +463,7 @@ export default function StoragePage() {
|
|||||||
<TableCell className="whitespace-nowrap font-mono text-xs">{o.out_trade_no}</TableCell>
|
<TableCell className="whitespace-nowrap font-mono text-xs">{o.out_trade_no}</TableCell>
|
||||||
<TableCell>{o.plan_label || "-"}</TableCell>
|
<TableCell>{o.plan_label || "-"}</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-sm font-medium">{formatAmount(o.amount_cents)}</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 className="whitespace-nowrap text-xs text-muted-foreground">{o.pay_provider === "wechat" ? "微信支付" : "历史订单"}</TableCell>
|
||||||
<TableCell>{orderStatusBadge(o.status)}</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 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">{fmtTime(o.created_at)}</TableCell>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ test('file UUID uses the stable API endpoint for thumbnails, original and copied
|
|||||||
const access = '/api/files/' + id
|
const access = '/api/files/' + id
|
||||||
assert.equal(mediaUrl(id), access)
|
assert.equal(mediaUrl(id), access)
|
||||||
assert.equal(mediaUrl(access), access)
|
assert.equal(mediaUrl(access), access)
|
||||||
assert.equal(new URL(mediaUrl(id), 'https://past.sundynix.cn/backend/admin/files').href, 'https://past.sundynix.cn' + access)
|
assert.equal(new URL(mediaUrl(id), 'https://we.sundynix.cn/backend/admin/files').href, 'https://we.sundynix.cn' + access)
|
||||||
assert.equal(mediaUrl('------------------------------------'), '/backend/------------------------------------?v=2')
|
assert.equal(mediaUrl('------------------------------------'), '/backend/------------------------------------?v=2')
|
||||||
assert.equal(mediaUrl(null), '')
|
assert.equal(mediaUrl(null), '')
|
||||||
assert.equal(mediaUrl('https://past.sundynix.cn/api/files/' + id), 'https://past.sundynix.cn/api/files/' + id)
|
assert.equal(mediaUrl('https://we.sundynix.cn/api/files/' + id), 'https://we.sundynix.cn/api/files/' + id)
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
// 开发工具使用本机服务;真机调试时把 dev.apiBase 改为电脑的局域网 IP。
|
// 开发工具使用本机服务;真机调试时把 dev.apiBase 改为电脑的局域网 IP。
|
||||||
const environments = {
|
const environments = {
|
||||||
dev: { apiBase: 'http://127.0.0.1:8080/api' },
|
dev: { apiBase: 'http://127.0.0.1:8080/api' },
|
||||||
trial: { apiBase: 'https://past.sundynix.cn/api' },
|
trial: { apiBase: 'https://we.sundynix.cn/api' },
|
||||||
release: { apiBase: 'https://past.sundynix.cn/api' }
|
release: { apiBase: 'https://we.sundynix.cn/api' }
|
||||||
};
|
};
|
||||||
let version = 'release';
|
let version = 'release';
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ Page(withDataCache(withListPagination({
|
|||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
try {
|
try {
|
||||||
var self = this;
|
var self = this;
|
||||||
var albumId = this.data.albumId;
|
var albumId = /^-\d+$/.test(String(this.data.albumId)) ? null : this.data.albumId;
|
||||||
if (!albumId) {
|
if (!albumId) {
|
||||||
var albums = itemsOf(await api.albumList({page:1,page_size:1}));
|
var albums = itemsOf(await api.albumList({page:1,page_size:1}));
|
||||||
if (albums && albums.length > 0) {
|
if (albums && albums.length > 0) {
|
||||||
@@ -322,18 +322,11 @@ Page(withDataCache(withListPagination({
|
|||||||
await requestSubscribeAfterAction('photo');
|
await requestSubscribeAfterAction('photo');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 上传前先预热 session,确保认证状态有效
|
var albumId = /^-\d+$/.test(String(this.data.albumId)) ? null : this.data.albumId;
|
||||||
// 解决 Android 端首次上传时 session 未初始化的问题
|
if (!albumId) {
|
||||||
try {
|
const albums = itemsOf(await api.albumList({ page: 1, page_size: 1 }));
|
||||||
wx.showLoading({ title: '准备中...', mask: true });
|
albumId = albums.length ? albums[0].id : null;
|
||||||
await api.checkAuth();
|
|
||||||
wx.hideLoading();
|
|
||||||
} catch (e) {
|
|
||||||
wx.hideLoading();
|
|
||||||
console.warn('Session 预热失败(不影响后续上传)', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var albumId = this.data.albumId;
|
|
||||||
if (!albumId) {
|
if (!albumId) {
|
||||||
wx.showLoading({ title: '创建相册...' });
|
wx.showLoading({ title: '创建相册...' });
|
||||||
var album = await api.albumAdd({ title: '默认相册', description: '我们的甜蜜照片' });
|
var album = await api.albumAdd({ title: '默认相册', description: '我们的甜蜜照片' });
|
||||||
@@ -341,6 +334,7 @@ Page(withDataCache(withListPagination({
|
|||||||
this.setData({ albumId: albumId });
|
this.setData({ albumId: albumId });
|
||||||
wx.hideLoading();
|
wx.hideLoading();
|
||||||
}
|
}
|
||||||
|
this.setData({ albumId: albumId });
|
||||||
var batchId = Date.now().toString();
|
var batchId = Date.now().toString();
|
||||||
|
|
||||||
// 自动定位:为未填写位置的照片自动获取地理位置(发布后自动显示)
|
// 自动定位:为未填写位置的照片自动获取地理位置(发布后自动显示)
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ Page(withDataCache({
|
|||||||
loadStorageInfo: async function() {
|
loadStorageInfo: async function() {
|
||||||
try {
|
try {
|
||||||
const d = await api.storageInfo();
|
const d = await api.storageInfo();
|
||||||
const freeBytes = d.free_bytes || d.freeBytes || 0;
|
const freeBytes = d.quota_bytes != null ? d.quota_bytes : (d.quotaBytes != null ? d.quotaBytes : (d.free_bytes || d.freeBytes || 0));
|
||||||
const usedBytes = d.used_bytes || d.usedBytes || 0;
|
const usedBytes = d.used_bytes || d.usedBytes || 0;
|
||||||
const isVip = !!(d.is_vip || d.isVip);
|
const isVip = !!(d.is_vip || d.isVip);
|
||||||
const usedText = formatStorageBytes(usedBytes);
|
const usedText = formatStorageBytes(usedBytes);
|
||||||
@@ -1045,11 +1045,11 @@ Page(withDataCache({
|
|||||||
const expire = d.vip_expire_at || d.vipExpireAt;
|
const expire = d.vip_expire_at || d.vipExpireAt;
|
||||||
this.setData({
|
this.setData({
|
||||||
storageVip: true,
|
storageVip: true,
|
||||||
storageText: '无限空间',
|
storageText: (d.is_unlimited || d.isUnlimited) ? '无限空间' : formatStorageBytes(freeBytes),
|
||||||
storagePercent: 0,
|
storagePercent: 0,
|
||||||
storageOverQuota: false,
|
storageOverQuota: !(d.is_unlimited || d.isUnlimited) && usedBytes>freeBytes,
|
||||||
storageUsedText: usedText,
|
storageUsedText: usedText,
|
||||||
storageExpireText: expire ? ('有效期至 ' + String(expire).slice(0, 10)) : '已开通无限空间'
|
storageExpireText: expire ? ('有效期至 ' + String(expire).slice(0, 10)) : '已开通容量套餐'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0;
|
const percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<view wx:if="{{storageVip}}" class="j-storage-card j-storage-vip" bindtap="goStorage">
|
<view wx:if="{{storageVip}}" class="j-storage-card j-storage-vip" bindtap="goStorage">
|
||||||
<view class="j-storage-top">
|
<view class="j-storage-top">
|
||||||
<text class="j-storage-label">存储空间</text>
|
<text class="j-storage-label">存储空间</text>
|
||||||
<text class="j-storage-val j-vip">👑 无限空间</text>
|
<text class="j-storage-val j-vip">👑 {{storageText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="j-storage-bottom">
|
<view class="j-storage-bottom">
|
||||||
<text class="j-storage-hint">已用 {{storageUsedText}}{{storageExpireText ? ' · ' + storageExpireText : ''}}</text>
|
<text class="j-storage-hint">已用 {{storageUsedText}}{{storageExpireText ? ' · ' + storageExpireText : ''}}</text>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<view class="j-storage-bar"><view class="j-storage-fill" style="width:{{storagePercent}}%;"></view></view>
|
<view class="j-storage-bar"><view class="j-storage-fill" style="width:{{storagePercent}}%;"></view></view>
|
||||||
<view class="j-storage-bottom">
|
<view class="j-storage-bottom">
|
||||||
<text class="j-storage-hint">{{storageOverQuota ? '已超出免费空间,升级后可继续上传' : '已用 ' + storagePercent + '% 免费空间'}}</text>
|
<text class="j-storage-hint">{{storageOverQuota ? '已超出免费空间,升级后可继续上传' : '已用 ' + storagePercent + '% 免费空间'}}</text>
|
||||||
<text class="j-storage-cta">{{storageOverQuota ? '升级无限空间 ›' : '查看 ›'}}</text>
|
<text class="j-storage-cta">{{storageOverQuota ? '扩容 ›' : '查看 ›'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="profile-statistics">
|
<view class="profile-statistics">
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ Page({
|
|||||||
percent: 0,
|
percent: 0,
|
||||||
overQuota: false,
|
overQuota: false,
|
||||||
isVip: false,
|
isVip: false,
|
||||||
|
isUnlimited:false,
|
||||||
|
quotaText:"200 MB",
|
||||||
vipExpireText: '',
|
vipExpireText: '',
|
||||||
plans: [],
|
plans: [],
|
||||||
paying: false,
|
paying: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad: function () { this.setData(getLayout()); },
|
onLoad: function () { this._unloaded = false; this.setData(getLayout()); },
|
||||||
|
onUnload: function () { this._unloaded = true; },
|
||||||
onShow: function () { this.loadInfo(); },
|
onShow: function () { this.loadInfo(); },
|
||||||
onPullDownRefresh: function () { this.loadInfo().then(function () { wx.stopPullDownRefresh(); }); },
|
onPullDownRefresh: function () { this.loadInfo().then(function () { wx.stopPullDownRefresh(); }); },
|
||||||
|
|
||||||
@@ -34,7 +37,8 @@ Page({
|
|||||||
var self = this;
|
var self = this;
|
||||||
this.setData({ loading: true, error: false });
|
this.setData({ loading: true, error: false });
|
||||||
return api.storageInfo().then(function (d) {
|
return api.storageInfo().then(function (d) {
|
||||||
var freeBytes = d.free_bytes || d.freeBytes || 0;
|
var freeBytes = d.quota_bytes != null ? d.quota_bytes : (d.quotaBytes != null ? d.quotaBytes : (d.free_bytes || d.freeBytes || 0));
|
||||||
|
var unlimited=!!(d.is_unlimited || d.isUnlimited);
|
||||||
var usedBytes = d.used_bytes || d.usedBytes || 0;
|
var usedBytes = d.used_bytes || d.usedBytes || 0;
|
||||||
var percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0;
|
var percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0;
|
||||||
var overQuota = usedBytes > freeBytes;
|
var overQuota = usedBytes > freeBytes;
|
||||||
@@ -43,25 +47,30 @@ Page({
|
|||||||
return {
|
return {
|
||||||
key: p.key,
|
key: p.key,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
priceText: (cents / 100).toFixed(cents % 100 === 0 ? 0 : 1),
|
priceCents:cents,
|
||||||
unit: p.key === 'year' ? '年' : '月',
|
version:p.updated_at || p.updatedAt,
|
||||||
productId: p.product_id || p.productId,
|
priceText: (cents / 100).toFixed(2),
|
||||||
|
unit: (p.months || (p.key==='year'?12:1))+'个月',
|
||||||
|
description: (Number(p.quota_mb != null ? p.quota_mb : p.quotaMb || 0)===0 ? '无限空间' : formatBytes(Number(p.quota_mb != null ? p.quota_mb : p.quotaMb)*1024*1024))+' · 有效期'+(p.months || (p.key==='year'?12:1))+'个月',
|
||||||
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
var vipExpireText = '';
|
var vipExpireText = '';
|
||||||
if (d.is_vip || d.isVip) {
|
if (d.is_vip || d.isVip) {
|
||||||
var expire = d.vip_expire_at || d.vipExpireAt ? String(d.vip_expire_at || d.vipExpireAt).slice(0, 10) : '';
|
var expire = d.vip_expire_at || d.vipExpireAt ? String(d.vip_expire_at || d.vipExpireAt).slice(0, 10) : '';
|
||||||
vipExpireText = expire ? ('有效期至 ' + expire) : '已开通无限空间';
|
vipExpireText = expire ? ('有效期至 ' + expire) : '已开通容量套餐';
|
||||||
}
|
}
|
||||||
self.setData({
|
self.setData({
|
||||||
loading: false,
|
loading: false,
|
||||||
usedBytes: usedBytes,
|
usedBytes: usedBytes,
|
||||||
usedText: formatBytes(usedBytes),
|
usedText: formatBytes(usedBytes),
|
||||||
freeMb: d.free_mb || d.freeMb || 200,
|
freeMb: d.free_mb != null ? d.free_mb : (d.freeMb != null ? d.freeMb : 200),
|
||||||
freeText: formatBytes(freeBytes),
|
freeText: formatBytes(freeBytes),
|
||||||
percent: percent,
|
percent: percent,
|
||||||
overQuota: overQuota,
|
overQuota: overQuota,
|
||||||
isVip: !!(d.is_vip || d.isVip),
|
isVip: !!(d.is_vip || d.isVip),
|
||||||
|
isUnlimited:unlimited,
|
||||||
|
quotaText:unlimited?"无限空间":formatBytes(freeBytes),
|
||||||
vipExpireText: vipExpireText,
|
vipExpireText: vipExpireText,
|
||||||
plans: plans,
|
plans: plans,
|
||||||
});
|
});
|
||||||
@@ -83,14 +92,14 @@ Page({
|
|||||||
|
|
||||||
wx.showLoading({ title: '下单中...', mask: true });
|
wx.showLoading({ title: '下单中...', mask: true });
|
||||||
this.setData({ paying: true });
|
this.setData({ paying: true });
|
||||||
api.storageCreateOrder({ plan_key: key }).then(function (order) {
|
api.storageCreateOrder({ plan_key: key, expected_price_cents:plan.priceCents, expected_plan_version:plan.version }).then(function (order) {
|
||||||
wx.hideLoading();
|
wx.hideLoading();
|
||||||
if (!order || !(order.sign_data || order.signData)) {
|
if (order && order.pay_provider === 'wechat' && order.payment) {
|
||||||
self.setData({ paying: false });
|
self.callWechatPay(order);
|
||||||
wx.showToast({ title: '下单失败', icon: 'none' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.callVirtualPay(order);
|
self.setData({ paying: false });
|
||||||
|
wx.showToast({ title: '支付参数不完整,请重新下单', icon: 'none' });
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
wx.hideLoading();
|
wx.hideLoading();
|
||||||
self.setData({ paying: false });
|
self.setData({ paying: false });
|
||||||
@@ -98,37 +107,54 @@ Page({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
callVirtualPay: function (order) {
|
callWechatPay: function (order) {
|
||||||
var self = this;
|
var self = this;
|
||||||
if (!wx.requestVirtualPayment) {
|
var payment = order.payment;
|
||||||
|
if (!wx.requestPayment) {
|
||||||
this.setData({ paying: false });
|
this.setData({ paying: false });
|
||||||
wx.showModal({
|
wx.showToast({ title: '当前环境不支持微信支付,请使用微信真机', icon: 'none' });
|
||||||
title: '版本过低',
|
|
||||||
content: '请升级微信至最新版本后再试。',
|
|
||||||
showCancel: false
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// wx.requestVirtualPayment 仅接受 signData / paySig / signature / mode 四个参数;
|
wx.requestPayment({
|
||||||
// offerId、env、productId、goodsPrice 等均已编码在 signData JSON 字符串内,不可再平铺为顶层参数。
|
timeStamp: payment.time_stamp,
|
||||||
wx.requestVirtualPayment({
|
nonceStr: payment.nonce_str,
|
||||||
signData: order.sign_data || order.signData,
|
package: payment.package,
|
||||||
paySig: order.pay_sig || order.paySig,
|
signType: payment.sign_type,
|
||||||
signature: order.signature,
|
paySign: payment.pay_sign,
|
||||||
mode: order.mode || 'short_series_goods',
|
success: function () { return self.confirmWechatPayment(order.out_trade_no); },
|
||||||
success: function () {
|
fail: function (error) {
|
||||||
self.setData({ paying: false });
|
// Even a cashier error can occur after payment; query the server once.
|
||||||
wx.showToast({ title: '支付成功', icon: 'success' });
|
var message = String(error && error.errMsg || '').indexOf('cancel') >= 0 ? '已取消支付' : '支付未完成';
|
||||||
setTimeout(function () { self.loadInfo(); }, 1200);
|
return self.confirmWechatPayment(order.out_trade_no, message);
|
||||||
},
|
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
confirmWechatPayment: async function (number, failureMessage) {
|
||||||
|
if (this._confirming) return;
|
||||||
|
this._confirming = true;
|
||||||
|
try {
|
||||||
|
for (var attempt = 0; attempt < (failureMessage ? 1 : 5); attempt++) {
|
||||||
|
var result = await api.storageOrderStatus(number);
|
||||||
|
if (this._unloaded) return;
|
||||||
|
if (result.status === 'success') {
|
||||||
|
wx.showToast({ title: '支付成功,会员已开通', icon: 'success' });
|
||||||
|
await this.loadInfo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === 'closed') {
|
||||||
|
wx.showToast({ title: '订单已关闭,请重新下单', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!failureMessage && attempt < 4) await new Promise(function (resolve) { setTimeout(resolve, 1200); });
|
||||||
|
}
|
||||||
|
if (!this._unloaded) wx.showToast({ title: failureMessage || '支付结果确认中,请稍后刷新', icon: 'none' });
|
||||||
|
} catch (e) {
|
||||||
|
if (!this._unloaded) wx.showToast({ title: '支付结果暂未确认,请稍后刷新', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
this._confirming = false;
|
||||||
|
if (!this._unloaded) this.setData({ paying: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,27 +9,27 @@
|
|||||||
<view class="storage-card">
|
<view class="storage-card">
|
||||||
<view class="storage-card-head">
|
<view class="storage-card-head">
|
||||||
<view class="storage-title">已用空间</view>
|
<view class="storage-title">已用空间</view>
|
||||||
<view class="storage-vip" wx:if="{{isVip}}">👑 无限空间</view>
|
<view class="storage-vip" wx:if="{{isVip}}">👑 {{quotaText}}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="storage-used">
|
<view class="storage-used">
|
||||||
<text class="storage-num">{{usedText}}</text>
|
<text class="storage-num">{{usedText}}</text>
|
||||||
<text class="storage-of" wx:if="{{!isVip}}"> / {{freeText}} 免费</text>
|
<text class="storage-of" wx:if="{{!isUnlimited}}"> / {{quotaText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="storage-bar" wx:if="{{!isVip}}">
|
<view class="storage-bar" wx:if="{{!isUnlimited}}">
|
||||||
<view class="storage-bar-fill" style="width: {{percent}}%;"></view>
|
<view class="storage-bar-fill" style="width: {{percent}}%;"></view>
|
||||||
</view>
|
</view>
|
||||||
<view class="storage-hint" wx:if="{{isVip}}">{{vipExpireText}}</view>
|
<view class="storage-hint" wx:if="{{isVip}}">{{vipExpireText}}</view>
|
||||||
<view class="storage-hint over" wx:elif="{{overQuota}}">已超出免费空间,升级后即可继续上传</view>
|
<view class="storage-hint over" wx:elif="{{overQuota}}">已超出免费空间,升级后即可继续上传</view>
|
||||||
<view class="storage-hint" wx:else>已使用 {{percent}}% 免费空间</view>
|
<view class="storage-hint" wx:else>已使用 {{percent}}% 存储空间</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 套餐 -->
|
<!-- 套餐 -->
|
||||||
<view class="section-heading">升级无限空间</view>
|
<view class="section-heading">选择容量套餐</view>
|
||||||
<view class="plan-list">
|
<view class="plan-list">
|
||||||
<view class="plan-card" wx:for="{{plans}}" wx:key="key" bindtap="buyPlan" data-key="{{item.key}}">
|
<view class="plan-card" wx:for="{{plans}}" wx:key="key" bindtap="buyPlan" data-key="{{item.key}}">
|
||||||
<view class="plan-info">
|
<view class="plan-info">
|
||||||
<view class="plan-name">{{item.name}}</view>
|
<view class="plan-name">{{item.name}}</view>
|
||||||
<view class="plan-desc">无限存储空间,照片视频想存就存</view>
|
<view class="plan-desc">{{item.description}}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="plan-price">
|
<view class="plan-price">
|
||||||
<text class="plan-amount">¥{{item.priceText}}</text>
|
<text class="plan-amount">¥{{item.priceText}}</text>
|
||||||
@@ -38,12 +38,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="storage-hint" wx:if="{{!loading && !plans.length}}">暂无上架套餐</view>
|
||||||
<!-- 说明 -->
|
<!-- 说明 -->
|
||||||
<view class="storage-note">
|
<view class="storage-note">
|
||||||
<view class="note-title">📌 说明</view>
|
<view class="note-title">📌 说明</view>
|
||||||
<view class="note-item">• 默认提供 {{freeMb}}MB 免费空间</view>
|
<view class="note-item">• 默认提供 {{freeMb}}MB 免费空间</view>
|
||||||
<view class="note-item">• 开通会员后,照片、视频等不再限制存储空间</view>
|
<view class="note-item">• 套餐容量为总可用容量,不与免费额度叠加;情侣共享当前有效的最大容量</view>
|
||||||
<view class="note-item">• 会员到期后,已上传内容仍可正常查看</view>
|
<view class="note-item">• 套餐到期后已上传内容仍可查看,超出可用容量将暂停新增上传</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<page-state wx:if="{{error}}" kind="error" title="加载失败" action="重试" bind:action="loadInfo" />
|
<page-state wx:if="{{error}}" kind="error" title="加载失败" action="重试" bind:action="loadInfo" />
|
||||||
|
|||||||
@@ -46,3 +46,26 @@ test('blank edit address uses current user location; a supplied address wins',as
|
|||||||
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' '));await p.savePhotoInfo();assert.equal(e.calls[0].location,'当前用户地址');assert.equal(e.calls[0].lat,25);assert.equal(p.data.batches[0].photos[0].location,'当前用户地址');
|
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' '));await p.savePhotoInfo();assert.equal(e.calls[0].location,'当前用户地址');assert.equal(e.calls[0].lat,25);assert.equal(p.data.batches[0].photos[0].location,'当前用户地址');
|
||||||
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' 手填地址 '));await p.savePhotoInfo();assert.equal(e.calls[1].location,'手填地址');assert.equal(e.calls[1].lat,0);
|
p.openPhotoEditor(event({batch:0}));p.onEditPhotoInput(event({field:'location'},' 手填地址 '));await p.savePhotoInfo();assert.equal(e.calls[1].location,'手填地址');assert.equal(e.calls[1].lat,0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function uploadSetup(auth) {
|
||||||
|
let definition;const calls=[],modals=[],toasts=[];
|
||||||
|
const wx={getStorageSync:()=>'',showToast:v=>toasts.push(v),showModal:v=>modals.push(v),showLoading(){},hideLoading(){},switchTab:v=>calls.push(['switchTab',v]),redirectTo:v=>calls.push(['redirectTo',v]),chooseImage:()=>calls.push(['picker'])};
|
||||||
|
const api={checkAuth:async()=>auth(),storageInfo:async()=>({is_unlimited:true}),albumList:async()=>({items:[]}),albumAdd:async()=>{calls.push(['albumAdd']);return {id:'c224bc8c-0848-442d-abf6-350fab34d669'};},photoAdd:async data=>{calls.push(['photoAdd',data]);return {success:true};}};
|
||||||
|
const load=createLoader({wx,console,Date,Promise,Page:p=>definition=p},{'../../utils/request':{api,getImageUrl:v=>v,API_BASE:'https://we.sundynix.cn/api',uploadFileCompat:async()=>{calls.push(['upload']);return {file_id:'6487966a-28ac-41d1-97b6-26f279b11fa9'};}},'../../utils/user-location':{currentUserLocation:async()=>({location:'',lat:0,lng:0})}});
|
||||||
|
load(path.join(__dirname,'../pages/album/album.js'));
|
||||||
|
const page={...definition,data:{...definition.data,albumId:'c224bc8c-0848-442d-abf6-350fab34d669',uploadFilePaths:['photo.jpg'],photoFormList:[{}]},setData(v){Object.assign(this.data,v);},loadData(){calls.push(['reload']);}};
|
||||||
|
return {page,calls,modals,toasts};
|
||||||
|
}
|
||||||
|
test('unbound users upload and save photos in their own UUID album',async()=>{
|
||||||
|
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:null}}));await e.page.uploadAll();
|
||||||
|
assert.equal(e.calls[0][0],'upload');assert.equal(e.calls[1][0],'photoAdd');assert.equal(e.calls[1][1].path,'6487966a-28ac-41d1-97b6-26f279b11fa9');assert.equal(e.calls[2][0],'reload');assert.equal(e.page.data.showUploadModal,false);assert.equal(e.modals.length,0);
|
||||||
|
});
|
||||||
|
test('bound users keep uploading to the existing shared album',async()=>{
|
||||||
|
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:'bound'}}));await e.page.uploadAll();
|
||||||
|
assert.equal(e.calls[1][0],'photoAdd');assert.equal(e.calls[1][1].album_id,'c224bc8c-0848-442d-abf6-350fab34d669');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale demo album is replaced with a real personal album before transferring photos',async()=>{
|
||||||
|
const e=uploadSetup(()=>({logged_in:true,user:{couple_id:null}}));e.page.data.albumId=-1;await e.page.uploadAll();
|
||||||
|
assert.equal(e.calls[0][0],'albumAdd');assert.equal(e.calls[1][0],'upload');assert.equal(e.calls[2][0],'photoAdd');assert.equal(e.calls[2][1].album_id,'c224bc8c-0848-442d-abf6-350fab34d669');
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ test('development requests and file previews use local backend, trial and releas
|
|||||||
const env = load(path.join(__dirname,'../config/env.js'));
|
const env = load(path.join(__dirname,'../config/env.js'));
|
||||||
const {API_BASE,getImageUrl} = load(path.join(__dirname,'../utils/request.js'));
|
const {API_BASE,getImageUrl} = load(path.join(__dirname,'../utils/request.js'));
|
||||||
assert.equal(env.env, version === 'develop' ? 'dev' : version);
|
assert.equal(env.env, version === 'develop' ? 'dev' : version);
|
||||||
assert.equal(API_BASE, version === 'develop' ? 'http://127.0.0.1:8080/api' : 'https://past.sundynix.cn/api');
|
assert.equal(API_BASE, version === 'develop' ? 'http://127.0.0.1:8080/api' : 'https://we.sundynix.cn/api');
|
||||||
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),API_BASE+'/files/6083e119-6b26-441b-8b70-44d224366238');
|
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),API_BASE+'/files/6083e119-6b26-441b-8b70-44d224366238');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function client(extra = {}) {
|
|||||||
}
|
}
|
||||||
test('business requests persist IDs, preserve order and distinguish clear from omitted fields', async () => {
|
test('business requests persist IDs, preserve order and distinguish clear from omitted fields', async () => {
|
||||||
const c = client();
|
const c = client();
|
||||||
await c.api.diaryUpdate({id:'diary',images:['https://past.sundynix.cn/api/files/'+id, id2]});
|
await c.api.diaryUpdate({id:'diary',images:['https://we.sundynix.cn/api/files/'+id, id2]});
|
||||||
await c.api.albumUpdate({id:'album',cover:''});
|
await c.api.albumUpdate({id:'album',cover:''});
|
||||||
await c.api.updateProfile({nickname:'test'});
|
await c.api.updateProfile({nickname:'test'});
|
||||||
await c.api.photoAdd({album_id:'album',path:'/api/files/'+id});
|
await c.api.photoAdd({album_id:'album',path:'/api/files/'+id});
|
||||||
@@ -32,7 +32,7 @@ test('duplicate and ID-only uploads keep stable display URLs and file metadata',
|
|||||||
assert.equal(uploadReference(result),id);
|
assert.equal(uploadReference(result),id);
|
||||||
assert.equal(result.duplicate,true);
|
assert.equal(result.duplicate,true);
|
||||||
assert.equal(result.file.url,'https://minio/raw-object');
|
assert.equal(result.file.url,'https://minio/raw-object');
|
||||||
assert.equal(c.getImageUrl(result.path),'https://past.sundynix.cn/api/files/'+id);
|
assert.equal(c.getImageUrl(result.path),'https://we.sundynix.cn/api/files/'+id);
|
||||||
assert.throws(()=>uploadReference({success:true,path:'http://tmp/new.jpg'}),/文件 ID/);
|
assert.throws(()=>uploadReference({success:true,path:'http://tmp/new.jpg'}),/文件 ID/);
|
||||||
});
|
});
|
||||||
test('diary edit uploads HTTP temporary images, retains existing IDs and stops on upload failure', async () => {
|
test('diary edit uploads HTTP temporary images, retains existing IDs and stops on upload failure', async () => {
|
||||||
@@ -55,5 +55,5 @@ test('media display keeps local assets and temporary paths without a backend dir
|
|||||||
assert.equal(c.getImageUrl('/images/default-avatar.png'), '/images/default-avatar.png');
|
assert.equal(c.getImageUrl('/images/default-avatar.png'), '/images/default-avatar.png');
|
||||||
assert.equal(c.getImageUrl('wxfile://tmp/photo.jpg'), 'wxfile://tmp/photo.jpg');
|
assert.equal(c.getImageUrl('wxfile://tmp/photo.jpg'), 'wxfile://tmp/photo.jpg');
|
||||||
assert.equal(c.getImageUrl('http://tmp/photo.jpg'), 'http://tmp/photo.jpg');
|
assert.equal(c.getImageUrl('http://tmp/photo.jpg'), 'http://tmp/photo.jpg');
|
||||||
assert.equal(c.getImageUrl(id), 'https://past.sundynix.cn/api/files/' + id);
|
assert.equal(c.getImageUrl(id), 'https://we.sundynix.cn/api/files/' + id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test('profile editing hides tabs until close and reserves a fixed save area outs
|
|||||||
});
|
});
|
||||||
test('photo memory detail uses light system controls and restores them on exit',()=>{
|
test('photo memory detail uses light system controls and restores them on exit',()=>{
|
||||||
const createLoader=require('./load-module.cjs');let definition;const colors=[];let back=0;
|
const createLoader=require('./load-module.cjs');let definition;const colors=[];let back=0;
|
||||||
const load=createLoader({Page:value=>definition=value,wx:{setNavigationBarColor:value=>colors.push(value),navigateBack:()=>back++},console,Date},{'../../utils/request':{api:{},getImageUrl:value=>'https://past.sundynix.cn/backend/'+value}});
|
const load=createLoader({Page:value=>definition=value,wx:{setNavigationBarColor:value=>colors.push(value),navigateBack:()=>back++},console,Date},{'../../utils/request':{api:{},getImageUrl:value=>'https://we.sundynix.cn/backend/'+value}});
|
||||||
load(path.join(root,'pages/timeline-detail/timeline-detail.js'));
|
load(path.join(root,'pages/timeline-detail/timeline-detail.js'));
|
||||||
const page={...definition,data:{...definition.data,item:{bg_image:'uploads/memory.jpg'}},setData(value){Object.assign(this.data,value);}};
|
const page={...definition,data:{...definition.data,item:{bg_image:'uploads/memory.jpg'}},setData(value){Object.assign(this.data,value);}};
|
||||||
page.loadBgImage();assert.equal(colors.at(-1).frontColor,'#ffffff');assert.ok(page.data.bgImageUrl.endsWith('memory.jpg'));
|
page.loadBgImage();assert.equal(colors.at(-1).frontColor,'#ffffff');assert.ok(page.data.bgImageUrl.endsWith('memory.jpg'));
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const createLoader = require('./load-module.cjs');
|
||||||
|
function setup(statuses) {
|
||||||
|
let definition, cashier, queries = 0, refreshes = 0;
|
||||||
|
const toasts = [];
|
||||||
|
const load = createLoader({Page(p) { definition = p; }, console, Promise, Date,
|
||||||
|
setTimeout(resolve) { resolve(); },
|
||||||
|
wx: { requestPayment(o) { cashier = o; }, showToast(o) { toasts.push(o); } }
|
||||||
|
}, {'../../utils/request': {api: {async storageOrderStatus(number) {
|
||||||
|
assert.equal(number, 'P12345678');
|
||||||
|
const result = statuses[Math.min(queries++, statuses.length - 1)];
|
||||||
|
if (result instanceof Error) throw result;
|
||||||
|
return result;
|
||||||
|
}}}});
|
||||||
|
load(path.join(__dirname,'../pages/storage/storage.js'));
|
||||||
|
const page = {...definition,data:{paying:true},setData(data){Object.assign(this.data,data);},async loadInfo(){refreshes++;}};
|
||||||
|
const order = {out_trade_no:'P12345678',payment:{time_stamp:'123',nonce_str:'nonce',package:'prepay_id=123',sign_type:'RSA',pay_sign:'signed'}};
|
||||||
|
page.callWechatPay(order);
|
||||||
|
return {page,toasts,get cashier(){return cashier;},get queries(){return queries;},get refreshes(){return refreshes;}};
|
||||||
|
}
|
||||||
|
test('JSAPI uses signed fields and only announces membership after server confirmation', async () => {
|
||||||
|
const e = setup([{status:'pending'},{status:'success'}]);
|
||||||
|
assert.equal(e.cashier.timeStamp,'123');
|
||||||
|
assert.equal(e.cashier.nonceStr,'nonce');
|
||||||
|
assert.equal(e.cashier.package,'prepay_id=123');
|
||||||
|
assert.equal(e.cashier.signType,'RSA');
|
||||||
|
assert.equal(e.cashier.paySign,'signed');
|
||||||
|
assert.equal(e.toasts.length,0);
|
||||||
|
await e.cashier.success();
|
||||||
|
assert.equal(e.queries,2);
|
||||||
|
assert.equal(e.refreshes,1);
|
||||||
|
assert.equal(e.toasts[0].title,'支付成功,会员已开通');
|
||||||
|
assert.equal(e.page.data.paying,false);
|
||||||
|
});
|
||||||
|
test('cancelled cashier checks for completed payment once, without granting pending orders',async()=>{
|
||||||
|
const e=setup([{status:'pending'}]);
|
||||||
|
await e.cashier.fail({errMsg:'requestPayment:fail cancel'});
|
||||||
|
assert.equal(e.queries,1);assert.equal(e.refreshes,0);
|
||||||
|
assert.equal(e.toasts[0].title,'已取消支付');assert.equal(e.page.data.paying,false);
|
||||||
|
const paid=setup([{status:'success'}]);
|
||||||
|
await paid.cashier.fail({errMsg:'requestPayment:fail'});
|
||||||
|
assert.equal(paid.refreshes,1);assert.equal(paid.toasts[0].title,'支付成功,会员已开通');
|
||||||
|
});
|
||||||
|
test('unknown payment results and network errors never claim success',async()=>{
|
||||||
|
for(const status of [{status:'pending'},Error('offline')]){
|
||||||
|
const e=setup([status]);await e.cashier.success();
|
||||||
|
assert.equal(e.refreshes,0);assert.equal(e.toasts[0].icon,'none');assert.equal(e.page.data.paying,false);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ test('server relation changes discard cached data before another read',async()=>
|
|||||||
await api.diaryList();await api.diaryList();assert.equal(reads,1);
|
await api.diaryList();await api.diaryList();assert.equal(reads,1);
|
||||||
await api.checkAuth();assert.equal(storage.get('coupleId'),'22');assert.equal(redirects,1);
|
await api.checkAuth();assert.equal(storage.get('coupleId'),'22');assert.equal(redirects,1);
|
||||||
const fresh=await api.diaryList();assert.equal(reads,2);assert.equal(fresh.items[0].title,'new space');
|
const fresh=await api.diaryList();assert.equal(reads,2);assert.equal(fresh.items[0].title,'new space');
|
||||||
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),'https://past.sundynix.cn/api/files/6083e119-6b26-441b-8b70-44d224366238');
|
assert.equal(getImageUrl('/api/files/6083e119-6b26-441b-8b70-44d224366238'),'https://we.sundynix.cn/api/files/6083e119-6b26-441b-8b70-44d224366238');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings and profile consent retain couple cache and lead into cooling',async()=>{
|
test('settings and profile consent retain couple cache and lead into cooling',async()=>{
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ test('GET business parameters and POST bodies use clean paths with auth', async
|
|||||||
const response = await api.diaryList({page:2,page_size:20});
|
const response = await api.diaryList({page:2,page_size:20});
|
||||||
assert.equal(response.page_size,20);
|
assert.equal(response.page_size,20);
|
||||||
await api.periodVisibleSet({period_visible:0});
|
await api.periodVisibleSet({period_visible:0});
|
||||||
assert.equal(calls[0].url,'https://past.sundynix.cn/api/diaryList?page=2&pageSize=20');
|
assert.equal(calls[0].url,'https://we.sundynix.cn/api/diaryList?page=2&pageSize=20');
|
||||||
assert.equal(calls[0].header.Authorization,'Bearer test-token');
|
assert.equal(calls[0].header.Authorization,'Bearer test-token');
|
||||||
assert.equal(calls[1].url,'https://past.sundynix.cn/api/periodVisibleSet');
|
assert.equal(calls[1].url,'https://we.sundynix.cn/api/periodVisibleSet');
|
||||||
assert.equal(calls[1].data.periodVisible,0);
|
assert.equal(calls[1].data.periodVisible,0);
|
||||||
});
|
});
|
||||||
test('multipart upload retains action and token when Android omits headers',async()=>{
|
test('multipart upload retains action and token when Android omits headers',async()=>{
|
||||||
let upload;
|
let upload;
|
||||||
const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
|
const {uploadFileCompat}=load('utils/request.js',{getStorageSync:()=> 'test-token',compressImage(o){o.fail();},uploadFile(o){upload=o;o.success({statusCode:200,data:'{"success":true,"path":"uploads/test.jpg"}'});} });
|
||||||
await uploadFileCompat({url:'https://past.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}});
|
await uploadFileCompat({url:'https://we.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}});
|
||||||
assert.equal(upload.url,'https://past.sundynix.cn/api/uploadPartnerAvatar?token=test-token');
|
assert.equal(upload.url,'https://we.sundynix.cn/api/uploadPartnerAvatar?token=test-token');
|
||||||
assert.equal(upload.formData.action,'uploadPartnerAvatar');
|
assert.equal(upload.formData.action,'uploadPartnerAvatar');
|
||||||
assert.equal(upload.formData.token,'test-token');
|
assert.equal(upload.formData.token,'test-token');
|
||||||
assert.equal(upload.formData.partnerId,87);
|
assert.equal(upload.formData.partnerId,87);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const createLoader=require('./load-module.cjs');
|
|||||||
function setup(action,info) {
|
function setup(action,info) {
|
||||||
let definition;const calls=[],toasts=[],events=[];
|
let definition;const calls=[],toasts=[],events=[];
|
||||||
const api={coupleInfo:async()=>({partner1:{id:1,nickname:'本人',avatar:'uploads/me.jpg'},partner2:{id:2,nickname:'伴侣',avatar:'uploads/partner.jpg'}}),smokingAction:async payload=>{calls.push(payload);return action(payload);},smokingInfo:info||(async()=>({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false}))};
|
const api={coupleInfo:async()=>({partner1:{id:1,nickname:'本人',avatar:'uploads/me.jpg'},partner2:{id:2,nickname:'伴侣',avatar:'uploads/partner.jpg'}}),smokingAction:async payload=>{calls.push(payload);return action(payload);},smokingInfo:info||(async()=>({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false}))};
|
||||||
const load=createLoader({Component:c=>definition=c,Date,Math,Promise,wx:{getStorageSync:()=>1,showToast:v=>toasts.push(v),showModal:o=>o.success({confirm:true}),navigateTo(){}}},{'../../utils/request':{api,getImageUrl:value=>value?'https://past.sundynix.cn/backend/'+value:''}});
|
const load=createLoader({Component:c=>definition=c,Date,Math,Promise,wx:{getStorageSync:()=>1,showToast:v=>toasts.push(v),showModal:o=>o.success({confirm:true}),navigateTo(){}}},{'../../utils/request':{api,getImageUrl:value=>value?'https://we.sundynix.cn/backend/'+value:''}});
|
||||||
load(path.join(__dirname,'../components/smoking-card/smoking-card.js'));
|
load(path.join(__dirname,'../components/smoking-card/smoking-card.js'));
|
||||||
const component={...definition.methods,properties:{detailed:false},data:JSON.parse(JSON.stringify(definition.data)),setData(values){Object.assign(this.data,values);},triggerEvent:name=>events.push(name)};
|
const component={...definition.methods,properties:{detailed:false},data:JSON.parse(JSON.stringify(definition.data)),setData(values){Object.assign(this.data,values);},triggerEvent:name=>events.push(name)};
|
||||||
component.applyInfo({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false});
|
component.applyInfo({stock:5,today_count:0,logs:[],daily:[],available:true,read_only:false});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const test=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const path=require('node:path');
|
||||||
|
const loader=require('./load-module.cjs');
|
||||||
|
test('finite paid space uses its quota, zero is respected, unlimited is explicit',()=>{
|
||||||
|
const load=loader({wx:{},console},{'./request':{api:{}}});
|
||||||
|
const {quotaExceeded}=load(path.join(__dirname,'../utils/storage-guard.js'));
|
||||||
|
assert.equal(quotaExceeded({isVip:true,isUnlimited:false,quotaBytes:1024,usedBytes:1000},25),true);
|
||||||
|
assert.equal(quotaExceeded({is_vip:true,is_unlimited:false,quota_bytes:1024,used_bytes:1000},24),false);
|
||||||
|
assert.equal(quotaExceeded({isVip:true,isUnlimited:true,quotaBytes:0,usedBytes:9999},100),false);
|
||||||
|
assert.equal(quotaExceeded({quotaBytes:0,freeBytes:200,usedBytes:1},0),true);
|
||||||
|
});
|
||||||
|
test('storage page renders 1-cent plans and custom month/capacity values',async()=>{
|
||||||
|
let definition;
|
||||||
|
const info={freeBytes:200*1024*1024,quotaBytes:1024*1024*1024,usedBytes:512*1024*1024,isVip:true,isUnlimited:false,vipExpireAt:'2026-12-02',plans:[{key:'custom',name:'测试套餐',priceCents:1,quotaMb:1024,months:3}]};
|
||||||
|
const load=loader({Page(p){definition=p},wx:{},console},{'../../utils/request':{api:{storageInfo:async()=>info}}});
|
||||||
|
load(path.join(__dirname,'../pages/storage/storage.js'));
|
||||||
|
const page={...definition,data:{...definition.data},setData(d){Object.assign(this.data,d)}};
|
||||||
|
await page.loadInfo();
|
||||||
|
assert.equal(page.data.plans[0].priceText,'0.01');
|
||||||
|
assert.equal(page.data.plans[0].unit,'3个月');
|
||||||
|
assert.match(page.data.plans[0].description,/1.00 GB/);
|
||||||
|
assert.equal(page.data.percent,50);
|
||||||
|
assert.equal(page.data.isVip,true);
|
||||||
|
assert.equal(page.data.isUnlimited,false);
|
||||||
|
assert.equal(page.data.quotaText,'1.00 GB');
|
||||||
|
});
|
||||||
@@ -73,7 +73,7 @@ function maybeRedirectStorage(data) {
|
|||||||
if (!isStorageQuotaError(data)) return false;
|
if (!isStorageQuotaError(data)) return false;
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
title: '存储空间不足',
|
title: '存储空间不足',
|
||||||
content: '免费空间已用完,升级为无限空间会员后即可继续上传。',
|
content: '存储空间已用完,请选择更大容量的套餐后继续上传。',
|
||||||
confirmText: '去升级',
|
confirmText: '去升级',
|
||||||
cancelText: '暂不',
|
cancelText: '暂不',
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
@@ -402,6 +402,7 @@ const api = {
|
|||||||
// 存储空间
|
// 存储空间
|
||||||
storageInfo: () => request('storageInfo', null, 'GET'),
|
storageInfo: () => request('storageInfo', null, 'GET'),
|
||||||
storageCreateOrder: (data) => request('storageCreateOrder', data),
|
storageCreateOrder: (data) => request('storageCreateOrder', data),
|
||||||
|
storageOrderStatus: (number) => request('storageOrderStatus', { out_trade_no: number }, 'GET'),
|
||||||
|
|
||||||
// 操作记录
|
// 操作记录
|
||||||
activityLogList: (data) => request('activityLogList', data, 'GET'),
|
activityLogList: (data) => request('activityLogList', data, 'GET'),
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
// 存储空间统一拦截:上传照片/图片前检查剩余空间,超出时在按钮处直接阻止上传。
|
// 存储空间统一拦截:上传照片/图片前检查剩余空间,超出时在按钮处直接阻止上传。
|
||||||
const { api } = require('./request');
|
const { api } = require('./request');
|
||||||
|
|
||||||
// 判断是否超出配额(会员无限空间,不拦截)
|
// 只有无限容量套餐免配额检查,付费有限套餐同样需要校验。
|
||||||
function quotaExceeded(d, extraBytes) {
|
function quotaExceeded(d, extraBytes) {
|
||||||
if (!d) return false;
|
if (!d) return false;
|
||||||
const isVip = d.is_vip || d.isVip;
|
const unlimited = d.is_unlimited || d.isUnlimited;
|
||||||
if (isVip) return false;
|
if (unlimited) return false;
|
||||||
const free = d.free_bytes || d.freeBytes || 0;
|
const free = d.quota_bytes != null ? d.quota_bytes : (d.quotaBytes != null ? d.quotaBytes : (d.free_bytes || d.freeBytes || 0));
|
||||||
const used = d.used_bytes || d.usedBytes || 0;
|
const used = d.used_bytes || d.usedBytes || 0;
|
||||||
return used + (extraBytes || 0) > free;
|
return used + (extraBytes || 0) > free;
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ function quotaExceeded(d, extraBytes) {
|
|||||||
function showQuotaBlocked() {
|
function showQuotaBlocked() {
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
title: '存储空间不足',
|
title: '存储空间不足',
|
||||||
content: '当前存储空间已满,请升级为无限空间会员后再上传照片。',
|
content: '当前存储空间已满,请选择更大容量的套餐后再上传照片。',
|
||||||
confirmText: '去升级',
|
confirmText: '去升级',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
# 虚拟支付接入修复 + 部署验收清单
|
|
||||||
|
|
||||||
## 一、本次修复的两个问题
|
|
||||||
|
|
||||||
### 问题 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