diff --git a/.gitignore b/.gitignore index 1b582bd..399b202 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ dist-ssr/ # 系统文件 .DS_Store Thumbs.db + +# 微信支付本地证书及私钥 +past-go/cert/ + +# zap 日志及压缩轮转文件 +log/ diff --git a/deploy/linux/nginx-past.conf b/deploy/linux/nginx-past.conf index a741623..3a53424 100644 --- a/deploy/linux/nginx-past.conf +++ b/deploy/linux/nginx-past.conf @@ -1,18 +1,46 @@ -# 宝塔网站 → 设置 → 配置文件,把下面整段放到 server { } 内(或改用宝塔“反向代理”UI) -# 作用:把域名的所有请求反代到本地 Go 后端(:8080),由宝塔 Nginx 负责 HTTPS/SSL。 +# Insert into the existing we.sundynix.cn HTTPS server block. +# Keep the existing SSL and ACME configuration. Replace matching locations. +location = / { + return 302 /backend/admin/login; +} -# 允许大文件上传(与 config.yaml 的 max_upload_mb 保持一致,默认 50MB) -client_max_body_size 50m; - -location / { - proxy_pass http://127.0.0.1:8080; - proxy_http_version 1.1; +location = /api { + 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; - # 大文件/慢请求超时,与后端 ReadTimeout 对应 - proxy_connect_timeout 60s; +} +location ^~ /api/ { + 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 ^~ /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; } diff --git a/deploy/linux/past.service b/deploy/linux/past.service index 24eccf2..a202e60 100644 --- a/deploy/linux/past.service +++ b/deploy/linux/past.service @@ -1,19 +1,21 @@ [Unit] -Description=past-go backend service -After=network.target mysql.service -Wants=mysql.service +Description=Past Go API +After=network-online.target postgresql.service redis-server.service +Wants=network-online.target +Requires=postgresql.service redis-server.service [Service] Type=simple -# 运行用户:宝塔默认站点用户为 www,需对该用户有 data_dir/uploads 写权限 User=www Group=www -# 改为你实际上传二进制与 config.yaml 的目录 -WorkingDirectory=/www/wwwroot/past -ExecStart=/www/wwwroot/past/past-server -config /www/wwwroot/past/config.yaml -Restart=always +WorkingDirectory=/home/workspace +ExecStart=/home/workspace/past-server -config /home/workspace/config.yaml +Environment=TZ=Asia/Shanghai +Restart=on-failure RestartSec=3 -# 标准输出/错误会进 journalctl;程序自身也会写 data/go-server.log +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true StandardOutput=journal StandardError=journal diff --git a/past-go/Makefile b/past-go/Makefile index dc6dcae..1b75906 100644 --- a/past-go/Makefile +++ b/past-go/Makefile @@ -4,7 +4,7 @@ GOOS ?= linux GOARCH ?= amd64 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 @@ -40,9 +40,5 @@ dev: build-local 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: rm -rf dist run/$(BIN) web/admin/dist diff --git a/past-go/README.md b/past-go/README.md index 3daa7bb..86ba095 100644 --- a/past-go/README.md +++ b/past-go/README.md @@ -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 调试 在 `past-go/` 目录执行 `make dev`,使用 `config.dev.yaml` 启动,监听 `:8080`。 @@ -8,8 +27,8 @@ 开发工具需关闭合法域名校验(项目已设置);真机调试将 dev 地址改为电脑的局域网 IP。 小程序 AppID 为 `wxb463820bf36dd5d6`,AppSecret 仅存放在后端本地配置。 dev 继续使用当前 `past-go` 数据库与 MinIO bucket,并非独立测试数据。 -日志和缓存写入 `run/dev/data/`,定时补发通知关闭。 -新小程序的订阅模板和虚拟支付参数需在对应平台获取后填写,dev 配置暂时留空。 +日志写入 `run/dev/log/YYYY-MM-DD/`,微信 access_token 缓存在 Redis,定时补发通知关闭。 +新小程序的订阅模板需在对应平台获取后填写。 # 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,测试结束清理;微信/地图外部请求使用模拟依赖。 -以下保留历史说明,数据库配置与部署步骤以以上内容为准。 +构建及部署:在 `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` 打进同一个二进制。 -小程序与管理后台统一使用驼峰 `/api/接口名`(如 `/api/periodList`),请求参数与响应字段同样使用驼峰。业务值与鉴权延续现有约定。旧 PHP 风格入口继续兼容已发布客户端。 +Redis 是服务启动依赖,通过 `redis.addr/password/db/key_prefix` 配置;本地、测试和生产应使用不同前缀或 DB。微信 token 按 AppID 缓存,按 `expires_in` 提前过期,通过 Redis 锁协调多实例刷新,不再读写 `data/access_token.cache`。Redis 不可用时启动失败。 -本次 Nginx 与部署说明见 [deploy/README.md](deploy/README.md)。 +新数据库的管理员账号密码为 `past / past`。已有数据库可显式执行 `./past-server -config config.yaml -reset-admin` 更新为该账号密码,保留原 `admin` 的 ID;正常启动不会重置已修改的密码。 -## 目录 -``` -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 每次请求执行)。 +存储收费支持多档套餐,在管理端「存储空间 → 存储收费设置」编辑名称、容量 MB(0 为无限)、有效月数、价格、上架状态和排序。套餐变更原子保存并检查配置版本,已购买权益及历史订单不随套餐修改或下架改变。新表 `past_storage_plan` 保存套餐,`past_storage_entitlement` 保存每笔权益快照;同容量续费顺延,不同容量各自到期,用户与当前伴侣共享最大有效容量,有限套餐不会绕过上传限制。到期后已有文件可查看,新增上传按剩余有效容量检查。 diff --git a/past-go/cmd/server/main.go b/past-go/cmd/server/main.go index 230dc4e..2ac98c7 100644 --- a/past-go/cmd/server/main.go +++ b/past-go/cmd/server/main.go @@ -5,8 +5,7 @@ import ( "context" "flag" "fmt" - "io" - "log/slog" + "go.uber.org/zap" "net/http" "os" "os/signal" @@ -15,14 +14,17 @@ import ( "time" _ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用 + "past/internal/cache" "past/internal/config" "past/internal/geo" "past/internal/handler" "past/internal/httpapi" + "past/internal/logging" "past/internal/notify" "past/internal/store" "past/internal/upload" "past/internal/wechat" + "past/internal/wxpay" "past/web" ) @@ -31,6 +33,7 @@ var version = "dev" func main() { cfgPath := flag.String("config", "config.yaml", "配置文件路径") + resetAdmin := flag.Bool("reset-admin", false, "将管理端账号密码设置为 past/past 后退出") showVer := flag.Bool("version", false, "显示版本") svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop") 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) }) { return } @@ -78,46 +101,52 @@ func serve(cfgPath string, stop <-chan struct{}) { cfg, err := config.Load(cfgPath) if err != nil { - slog.Error("配置加载失败", "err", err) + fmt.Fprintln(os.Stderr, "配置加载失败:", err) os.Exit(1) } - // 确保必要目录存在(对应 config.php 中的 $dirs) - for _, d := range []string{"data", "data/tmp"} { - os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755) + log, closeLog, err := logging.New(cfg.LogDir(), cfg.Log) + if err != nil { + fmt.Fprintln(os.Stderr, "日志初始化失败:", err) + os.Exit(1) } - // 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径) - logOut := io.Writer(os.Stdout) - if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { - logOut = io.MultiWriter(os.Stdout, f) - defer f.Close() + defer closeLog() + undoZap := zap.ReplaceGlobals(log) + defer undoZap() + undoStdLog := zap.RedirectStdLog(log) + 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 { - slog.Error("创建 PostgreSQL 数据库失败", "err", err) + zap.S().Errorw("创建 PostgreSQL 数据库失败", "err", err) os.Exit(1) } db, err := store.Open(cfg.DSN()) if err != nil { - slog.Error("数据库连接失败", "err", err) + zap.S().Errorw("数据库连接失败", "err", err) os.Exit(1) } db.NumericAsString = cfg.PHPCompat < "8.1" if err := db.InitSchema(); err != nil { - slog.Error("数据库初始化失败", "err", err) + zap.S().Errorw("数据库初始化失败", "err", err) os.Exit(1) } if err := db.MigrateSmokingRecords(); err != nil { - slog.Error("抽烟库存迁移失败", "err", err) + zap.S().Errorw("抽烟库存迁移失败", "err", err) os.Exit(1) } if err := db.AdvanceUnbindings(); err != nil { - slog.Error("解绑状态处理失败", "err", err) + zap.S().Errorw("解绑状态处理失败", "err", err) os.Exit(1) } 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{ Cfg: cfg, DB: db, @@ -125,27 +154,27 @@ func serve(cfgPath string, stop <-chan struct{}) { Geo: geo.New(cfg.TencentMapKey), 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) if err != nil { - slog.Error("MinIO 初始化失败", "err", err) + zap.S().Errorw("MinIO 初始化失败", "err", err) os.Exit(1) } router := httpapi.NewRouter(app, web.AdminFS()) handler.Register(router, uploader) - slog.Info("已注册 action", "count", len(router.Actions())) + zap.S().Infow("已注册 action", "count", len(router.Actions())) if cfg.NotifyIntervalMin > 0 { go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute) } - // 虚拟支付兜底查单:发货推送丢失/延迟时,通过 query_order 主动补发货。 - if cfg.VirtualPay.OfferID != "" { - interval := time.Duration(cfg.NotifyIntervalMin) * time.Minute - if interval <= 0 { - interval = 5 * time.Minute - } - go handler.RunStorageOrderQuery(db, wx, cfg, interval) - } srv := &http.Server{ Addr: cfg.Listen, @@ -156,9 +185,9 @@ func serve(cfgPath string, stop <-chan struct{}) { IdleTimeout: 120 * time.Second, } 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 { - slog.Error("服务异常退出", "err", err) + zap.S().Errorw("服务异常退出", "err", err) os.Exit(1) } }() @@ -167,5 +196,5 @@ func serve(cfgPath string, stop <-chan struct{}) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() srv.Shutdown(ctx) - slog.Info("服务已停止") + zap.S().Infow("服务已停止") } diff --git a/past-go/cmd/server/service_windows.go b/past-go/cmd/server/service_windows.go index 005a306..41a09fc 100644 --- a/past-go/cmd/server/service_windows.go +++ b/past-go/cmd/server/service_windows.go @@ -73,7 +73,7 @@ func serviceControl(cmd, cfgPath string) error { } s, err := m.CreateService(svcName, exe, mgr.Config{ DisplayName: "我们past Go 服务 (past-server)", - Description: "past.sundynix.cn 后端 API 与管理后台", + Description: "we.sundynix.cn 后端 API 与管理后台", StartType: mgr.StartAutomatic, }, "-config", abs) if err != nil { diff --git a/past-go/config.dev.example.yaml b/past-go/config.dev.example.yaml index 0615420..3a64055 100644 --- a/past-go/config.dev.example.yaml +++ b/past-go/config.dev.example.yaml @@ -1,9 +1,8 @@ # 本地 dev 配置(模板,复制为 config.dev.yaml 后补充密钥) listen: ":8080" -# 本地日志与缓存独立目录;文件存储使用下方 MinIO +# 本地运行目录:log/ 存日志,data/tmp/ 存临时文件;文件存储使用 MinIO data_dir: "./run/dev" debug: true -php_compat: "8.0" db: host: 192.168.100.127 port: 5432 @@ -19,19 +18,36 @@ tencent_map_key: "" max_upload_mb: 50 # 定时补发通知间隔(分钟),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: endpoint: https://s3.sundynix.cn access_key: your_minio_access_key secret_key: your_minio_secret_key bucket: past-go 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 diff --git a/past-go/config.example.yaml b/past-go/config.example.yaml index c0997b2..ac4f8f3 100644 --- a/past-go/config.example.yaml +++ b/past-go/config.example.yaml @@ -17,20 +17,36 @@ tencent_map_key: "" max_upload_mb: 50 # 定时补发通知间隔(分钟),0 关闭 notify_interval_min: 5 -# 微信小程序虚拟支付(米大师虚拟支付2.0) -virtual_pay: - appid: wxdc40d0c51cf8fe1e # 小程序 AppID(默认复用 wechat.appid) - offer_id: "1450687270" # 支付应用 ID - app_key: "" # 正式环境 AppKey(上线后填写) - sandbox_key: "OVuTGBk28DKbvyKek7KrbDRyfZDKpk4P" # 沙箱环境 AppKey - env: 1 # 0-正式环境 1-沙箱环境 - notify_token: "2029807d6e6441149c138eee3254c27c" # 支付结果回调 Token(微信后台消息推送配置) - notify_aes_key: "" # 支付结果回调 EncodingAESKey(可选) - - minio: endpoint: https://s3.sundynix.cn access_key: your_minio_access_key secret_key: your_minio_secret_key bucket: past-go 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 diff --git a/past-go/deploy/README.md b/past-go/deploy/README.md index 0f3c93c..e784f34 100644 --- a/past-go/deploy/README.md +++ b/past-go/deploy/README.md @@ -57,9 +57,9 @@ Go 全套和前端回归在本机执行;尚未在生产数据库或微信真 2. 启动新程序。本次没有新增数据库迁移,保留此前生理期共享功能的数据结构。 3. 如果上一版 `/api`、`/api/` 转发已配置并能访问,这次无需再改 Nginx:驼峰路径继续走同一段转发。附带配置已按你提供的 Nginx 使用 `127.0.0.1:8081`;保留现有 `/backend/` 与 uploads 缓存配置。 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` 等驼峰路径。 -7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://past.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。 +7. 微信开发者工具打开包里的 `weapp`;request/uploadFile 合法域名仍为 `https://we.sundynix.cn`。重新编译、真机预览,再上传小程序新版本。 ## 接口规则 @@ -85,3 +85,14 @@ Go 全套和前端回归在本机执行;尚未在生产数据库或微信真 ## 本次 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`;新数据库也使用该默认账号。普通启动不会重置密码。 diff --git a/past-go/deploy/nginx-api.conf b/past-go/deploy/nginx-api.conf index d8bc32c..03d43c0 100644 --- a/past-go/deploy/nginx-api.conf +++ b/past-go/deploy/nginx-api.conf @@ -1,5 +1,9 @@ -# 放在 past.sundynix.cn 现有 HTTPS server 块里。 +# 放在 we.sundynix.cn 现有 HTTPS server 块里。 # proxy_pass 末尾不要加 /,需要保留 /api/接口名。 +location = / { + return 302 /backend/admin/login; +} + location = /api { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; diff --git a/past-go/deploy/windows/README.md b/past-go/deploy/windows/README.md index 9da4e0e..805e53a 100644 --- a/past-go/deploy/windows/README.md +++ b/past-go/deploy/windows/README.md @@ -44,7 +44,7 @@ windows/ ## 部署步骤 1. **准备数据库**:MySQL 建库 `past`、创建用户,确保服务所在机器可连接。 -2. **填写配置**:编辑 `config.yaml`(数据库、微信、虚拟支付等)。 +2. **填写配置**:编辑 `config.yaml`(数据库、微信、微信支付 API v3 等)。 - 首次安装:复制 `config.example.yaml` 为 `config.yaml` 后填写。 - **确认 `listen` 端口与 Nginx 转发端口一致**(`config.example.yaml` 示例为 `:8080`, `../nginx-api.conf` 按 `127.0.0.1:8081` 转发,二者需保持一致)。 diff --git a/past-go/go.mod b/past-go/go.mod index 2c7d280..72e84db 100644 --- a/past-go/go.mod +++ b/past-go/go.mod @@ -3,11 +3,17 @@ module past go 1.26.4 require ( + github.com/alicebob/miniredis/v2 v2.40.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/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/sys v0.48.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.3 gorm.io/gorm v1.31.2 @@ -27,7 +33,6 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/twitchyliquid64/golang-asm v0.15.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 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 golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.60.0 // indirect diff --git a/past-go/go.sum b/past-go/go.sum index f2c959c..0f3584d 100644 --- a/past-go/go.sum +++ b/past-go/go.sum @@ -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/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= 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/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= 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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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.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/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= 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/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 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= 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.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/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/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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/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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/past-go/internal/cache/redis.go b/past-go/internal/cache/redis.go new file mode 100644 index 0000000..6947cd9 --- /dev/null +++ b/past-go/internal/cache/redis.go @@ -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 +} diff --git a/past-go/internal/config/config.go b/past-go/internal/config/config.go index b82b11b..9b96843 100644 --- a/past-go/internal/config/config.go +++ b/past-go/internal/config/config.go @@ -13,6 +13,22 @@ import ( "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 { Endpoint string `yaml:"endpoint"` AccessKey string `yaml:"access_key"` @@ -21,13 +37,37 @@ type MinioConfig struct { 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 { - Minio MinioConfig `yaml:"minio"` + Redis RedisConfig `yaml:"redis"` + Log LogConfig `yaml:"log"` + WechatPay WechatPayConfig `yaml:"wechat_pay"` + Minio MinioConfig `yaml:"minio"` Listen string `yaml:"listen"` // 监听地址,如 ":8080" Debug bool `yaml:"debug"` // gin debug 模式(打印路由等) - // DataDir 运行数据目录:data/ 存日志与缓存;新增上传文件保存在 MinIO。 + // DataDir 运行目录:log/ 存日志,data/tmp/ 存临时文件;文件存储在 MinIO。 // 对应 PHP 的 backend/ 目录;默认为可执行文件所在目录。 DataDir string `yaml:"data_dir"` @@ -57,21 +97,12 @@ type Config struct { // Notify 定时补发通知(对应 cron_notify.php)的执行间隔(分钟),0 表示关闭 NotifyIntervalMin int `yaml:"notify_interval_min"` - - // VirtualPay 微信小程序虚拟支付(米大师虚拟支付2.0)配置 - VirtualPay struct { - AppID string `yaml:"appid"` // 小程序 AppID(默认复用 wechat.appid) - OfferID string `yaml:"offer_id"` // 支付应用 ID - AppKey string `yaml:"app_key"` // 正式环境 AppKey - SandboxKey string `yaml:"sandbox_key"` // 沙箱环境 AppKey - Env int `yaml:"env"` // 0-正式环境 1-沙箱环境 - NotifyToken string `yaml:"notify_token"` // 支付结果回调 Token(微信后台消息推送配置) - NotifyAESKey string `yaml:"notify_aes_key"` // 支付结果回调 EncodingAESKey(可选,用于解密加密回调) - } `yaml:"virtual_pay"` } func Load(path string) (*Config, error) { 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.DB.Host = "192.168.100.127" c.DB.Port = 5432 @@ -83,7 +114,6 @@ func Load(path string) (*Config, error) { c.NotifyIntervalMin = 5 c.Wechat.NotifyTemplateID = "tOaqM_Z6pNSih2pVpaUmP86MgwnDT5ZlwOqd0lgmXP8" c.PHPCompat = "8.0" - c.VirtualPay.Env = 1 b, err := os.ReadFile(path) if err != nil { @@ -92,6 +122,18 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(b, c); err != nil { 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 == "" { exe, err := os.Executable() 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) 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. func (c *Config) DSN() string { diff --git a/past-go/internal/config/config_test.go b/past-go/internal/config/config_test.go index 3ca9966..39be6b6 100644 --- a/past-go/internal/config/config_test.go +++ b/past-go/internal/config/config_test.go @@ -2,6 +2,8 @@ package config import ( "github.com/jackc/pgx/v5" + "os" + "path/filepath" "testing" ) @@ -27,13 +29,35 @@ func TestDevExampleConfig(t *testing.T) { if err != nil { 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") } 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") } - 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") } } + +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") + } +} diff --git a/past-go/internal/handler/admin.go b/past-go/internal/handler/admin.go index b26bae4..de58837 100644 --- a/past-go/internal/handler/admin.go +++ b/past-go/internal/handler/admin.go @@ -404,8 +404,8 @@ func adminChangePassword(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - if len(c.InStr("new_password")) < 6 { - return c.Err(400, "新密码至少6位") + if len(c.InStr("new_password")) < 4 { + return c.Err(400, "新密码至少4位") } sess := c.AdminSession() admin, _ := c.DB.QueryOne("SELECT * FROM admins WHERE id = ?", sess.AdminID) diff --git a/past-go/internal/handler/album.go b/past-go/internal/handler/album.go index c7dfa54..fefd17c 100644 --- a/past-go/internal/handler/album.go +++ b/past-go/internal/handler/album.go @@ -34,15 +34,22 @@ func coupleSpaceID(c *httpapi.Ctx, cid string) string { return row.Str("couple_space_id") } -func albumList(c *httpapi.Ctx) error { - ids := coupleUserIds(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}) +// 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 { + ids := albumUserIds(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...)...) if err != nil { @@ -63,11 +70,12 @@ func albumAdd(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - cid, err := requireCoupleID(c) - if err != nil { - return err - } + cid := c.CoupleID() uid := c.UID() + var albumCouple any + if cid != "" { + albumCouple = cid + } if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil { return err } @@ -77,12 +85,12 @@ func albumAdd(c *httpapi.Ctx) error { 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)", - 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 { return err } 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 != "" { c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ "thing1": "新相册「" + mbTruncate(title, 20) + "」", @@ -98,10 +106,8 @@ func albumUpdate(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) + var err error if err := checkTextOr400(c, strings.TrimSpace(c.InStr("title")+" "+c.InStr("description"))); err != nil { return err } @@ -131,10 +137,7 @@ func albumDelete(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) 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 { return c.Err(404, "相册不存在") @@ -152,7 +155,7 @@ func setAlbumCover(c *httpapi.Ctx) error { if c.Method != "POST" { return c.ErrCode(405, "方法不允许", upload.ErrMethodNotAllowed) } - ids := coupleUserIds(c, "") + ids := albumUserIds(c) albumID := c.Form("album_id") var a store.Row if len(ids) == 2 { @@ -194,7 +197,7 @@ func photos(c *httpapi.Ctx) error { if albumID == "" { albumID = "0" } - ids := coupleUserIds(c, "") + ids := albumUserIds(c) if len(ids) == 0 { return listResponse(c, nil, 0, nil) } @@ -221,15 +224,11 @@ func photoUpload(c *httpapi.Ctx) error { if uid == "" { return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) } - cid := c.CoupleID() - if cid == "" { - return c.ErrCode(400, "请先绑定TA", upload.ErrNotBound) - } albumID := c.Form("album_id") if albumID == "" { 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) } description := c.Form("description") @@ -295,7 +294,7 @@ func photoUpload(c *httpapi.Ctx) error { return c.ErrCode(400, "上传失败: "+strings.Join(errors, "; "), upload.ErrHandleFailed) } 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 != "" { c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ "thing1": "相册有更新", @@ -316,10 +315,8 @@ func photoDelete(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) + var err error 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]) if err != nil { @@ -345,15 +342,11 @@ func photoAdd(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - cid, err := requireCoupleID(c) - if err != nil { - return err - } if !c.InBool("album_id") || !c.InBool("path") { return c.Err(400, "参数不完整") } 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, "相册不存在") } uid := c.UID() @@ -368,7 +361,7 @@ func photoAdd(c *httpapi.Ctx) error { return err } 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 != "" { c.App.Wechat.SendSubscribeMessage(partnerID, "notify", map[string]string{ "thing1": "相册有更新", @@ -385,10 +378,8 @@ func photoUpdate(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - ids, err := requireCoupleUserIds(c) - if err != nil { - return err - } + ids := albumUserIds(c) + var err error id := c.InTrim("id") if id == "" || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") { return c.Err(400, "参数不完整") diff --git a/past-go/internal/handler/api_postgres_test.go b/past-go/internal/handler/api_postgres_test.go index 43fb699..ab803ad 100644 --- a/past-go/internal/handler/api_postgres_test.go +++ b/past-go/internal/handler/api_postgres_test.go @@ -5,12 +5,14 @@ import ( "context" "encoding/json" "fmt" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" "image" "image/color" "image/draw" "image/png" "io" - "log/slog" "mime/multipart" "net/http" "net/http/httptest" @@ -92,6 +94,13 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { if err := db.InitSchema(); err != nil { 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 { t.Fatal(err) } @@ -99,16 +108,18 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { db.NumericAsString = stringsMode dataDir := t.TempDir() 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 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"}` 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 }() - oldLog := slog.Default() - slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) - defer slog.SetDefault(oldLog) + undoLog := zap.ReplaceGlobals(zap.NewNop()) + defer undoLog() 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")}}) testConfig := os.Getenv("PAST_TEST_CONFIG") @@ -183,9 +194,6 @@ func runPostgresAPI(t *testing.T, stringsMode bool) { } var value any 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()) } 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"}) get("check_auth", 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("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) } } + 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"}) diary := post("diary_add", aToken, M{"title": "Diary A", "content": "body", "diary_date": today(), "images": []string{}}) 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("subscribe_template", aToken, M{"template_id": "test-template", "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_avatar", aToken, "avatar", nil) 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_reset_user_password", "admin", M{"user_id": outsider["user_id"], "new_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"}) for _, term := range []string{store.ToStr(a["display_id"]), aID, "Alpha"} { 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 { 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}) - if _, err := db.Exec("UPDATE users SET session_key=? WHERE id=?", "test-session", aID); err != nil { - t.Fatal(err) - } - 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 := "xpay_goods_deliver_notify" + store.ToStr(yearOrder["out_trade_no"]) + "wx-year-order" - 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(), "0") { - 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("admin_storage_settings", "admin", M{"free_mb": 200, "month_price_cents": 500, "year_price_cents": 2990}) + exerciseWechatPayment(t, db, app, engine, request, covered, happy, aID, aToken, otherToken) + exerciseStorageTiers(t, db, app, request, aID, aToken, bToken) post("wechat_login", "", M{"code": "mock-wechat-code"}) // Three separate couples verify all binding entry points without reusing owners. 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. - 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() { if !public[action] { request(action, "GET", "", nil, 401) diff --git a/past-go/internal/handler/diary.go b/past-go/internal/handler/diary.go index d027a3d..0891024 100644 --- a/past-go/internal/handler/diary.go +++ b/past-go/internal/handler/diary.go @@ -9,8 +9,8 @@ import ( "past/internal/store" ) -// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://past.sundynix.cn/backend/'),MEDIA_BASE 未定义 -const mediaBase = "https://past.sundynix.cn/backend/" +// mediaBase 对应 PHP 中 (defined('MEDIA_BASE') ? MEDIA_BASE : 'https://we.sundynix.cn/backend/'),MEDIA_BASE 未定义 +const mediaBase = "https://we.sundynix.cn/backend/" var tagRe = regexp.MustCompile(`<[^>]*>`) diff --git a/past-go/internal/handler/storage.go b/past-go/internal/handler/storage.go index 4e0ab76..7ef7bc5 100644 --- a/past-go/internal/handler/storage.go +++ b/past-go/internal/handler/storage.go @@ -1,39 +1,29 @@ package handler import ( - "encoding/json" - "encoding/xml" - "fmt" - "log/slog" + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "past/internal/model" "sort" "strings" "time" - "past/internal/config" "past/internal/httpapi" "past/internal/store" - "past/internal/virtualpay" - "past/internal/wechat" ) const ( - settingFreeMB = "storage_free_mb" - settingMonthPrice = "storage_plan_month_price" - settingMonthProduct = "storage_plan_month_product" - settingYearPrice = "storage_plan_year_price" - settingYearProduct = "storage_plan_year_product" - settingPayMode = "storage_pay_mode" - settingPayOfferID = "storage_pay_offer_id" - settingPayAppKey = "storage_pay_app_key" - settingPaySandboxKey = "storage_pay_sandbox_key" - settingPayEnv = "storage_pay_env" + settingFreeMB = "storage_free_mb" + settingMonthPrice = "storage_plan_month_price" + settingYearPrice = "storage_plan_year_price" ) const ( - defaultFreeMB int64 = 200 - defaultMonthPrice int64 = 500 - defaultYearPrice int64 = 2990 - defaultPayMode string = "short_series_goods" + defaultFreeMB int64 = 200 + defaultMonthPrice int64 = 500 + defaultYearPrice int64 = 2990 ) // ErrStorageQuotaExceeded 上传时存储超额的错误码,前端据此引导付费。 @@ -41,9 +31,10 @@ const ErrStorageQuotaExceeded = 10001 func registerStorage(r *httpapi.Router) { 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_order_create", storageOrderCreate) - r.Handle("storage_pay_notify", storagePayNotify) r.Handle("admin_storage_settings", adminStorageSettings) r.Handle("admin_storage_users", adminStorageUsers) r.Handle("admin_storage_user_update", adminStorageUserUpdate) @@ -58,33 +49,6 @@ func storageSettingInt(c *httpapi.Ctx, key string, def int64) int64 { 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. func allStorageUsedBytes() int64 { if up == nil || up.DB == nil { @@ -92,7 +56,7 @@ func allStorageUsedBytes() int64 { } size, err := store.UsedFileBytes(up.DB.ORM, nil) if err != nil { - slog.Error("读取存储用量失败", "err", err) + zap.S().Errorw("读取存储用量失败", "err", err) } return size } @@ -103,7 +67,7 @@ func storageUsedBytes(c *httpapi.Ctx, uid, coupleId string) int64 { } size, err := store.UsedFileBytes(c.DB.ORM, owners) if err != nil { - slog.Error("读取用户存储用量失败", "err", err) + zap.S().Errorw("读取用户存储用量失败", "err", err) } return size } @@ -117,24 +81,15 @@ func storageVipUserIDs(c *httpapi.Ctx, uid string) []string { } func isStorageVip(c *httpapi.Ctx, uid string) bool { - for _, id := range storageVipUserIDs(c, uid) { - row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id) - if row != nil { - return true - } - } - return false + a, err := store.StorageSpace(c.DB.ORM, uid) + return err == nil && a.IsVip } - func storageVipExpireAt(c *httpapi.Ctx, uid string) string { - var latest string - for _, id := range storageVipUserIDs(c, uid) { - row, _ := c.DB.QueryOne("SELECT storage_vip_expire_at FROM users WHERE id = ? AND storage_vip_expire_at > NOW()", id) - if row != nil && row.Str("storage_vip_expire_at") > latest { - latest = row.Str("storage_vip_expire_at") - } + a, err := store.StorageSpace(c.DB.ORM, uid) + if err == nil && a.ExpiresAt != nil { + return a.ExpiresAt.Format("2006-01-02 15:04:05") } - return latest + return "" } // storageQuotaMB 返回用户实际可用的免费存储容量(MB): @@ -155,21 +110,29 @@ func storageInfo(c *httpapi.Ctx) error { } coupleID := c.CoupleID() freeMB := storageQuotaMB(c, uid) - vipExpire := storageVipExpireAt(c, uid) - plans := []M{ - {"key": "month", "name": "包月无限空间", "price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice), "product_id": storageSettingStr(c, settingMonthProduct, "")}, - {"key": "year", "name": "包年无限空间", "price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), "product_id": storageSettingStr(c, settingYearProduct, "")}, + plans, err := storageCatalog(c.DB.ORM, true) + if err != nil { + return err + } + allowance, err := store.StorageSpace(c.DB.ORM, uid) + if err != nil { + return err } resp := M{ + "pay_provider": "wechat", "used_bytes": storageUsedBytes(c, uid, coupleID), "free_mb": freeMB, "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, "plans": plans, } - if vipExpire != "" { - resp["vip_expire_at"] = vipExpire + if allowance.ExpiresAt != nil { + resp["vip_expire_at"] = allowance.ExpiresAt.Format("2006-01-02 15:04:05") } return c.OK(resp) } @@ -178,275 +141,79 @@ func storageOrderCreate(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - uid := c.UID() - if uid == "" { + if c.UID() == "" { return c.Err(401, "请先登录") } - planKey := c.InTrim("plan_key") - var price int64 - var productID string - switch planKey { - case "month": - price = storageSettingInt(c, settingMonthPrice, defaultMonthPrice) - productID = storageSettingStr(c, settingMonthProduct, "") - case "year": - price = storageSettingInt(c, settingYearPrice, defaultYearPrice) - productID = storageSettingStr(c, settingYearProduct, "") - default: - return c.Err(400, "无效的套餐") + if !c.App.Cfg.WechatPay.Enabled { + return c.Err(503, "微信支付服务未启用") } - if productID == "" { - return c.Err(400, "该套餐暂未开放,请稍后再试") + var plan model.StoragePlan + 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 user == nil || user.Str("session_key") == "" { - return c.Err(400, "支付凭证已过期,请重新登录后重试") + if r.RowsAffected == 0 { + return c.Err(400, "套餐已下架或不存在,请刷新") } - vp := vpayClient(c) - if vp.OfferID == "" || vp.AppKey == "" { - return c.Err(500, "支付服务未配置,请联系管理员") + if c.InHas("expected_price_cents") && c.InInt("expected_price_cents") != plan.PriceCents { + return c.Err(409, "套餐价格已变更,请刷新后重新购买") } - // outTradeNo 要求 8-32 位、不能以下划线开头;NewToken 为 32 位 hex,取前 31 位 + 前缀 S 共 32 位。 - outTradeNo := "S" + httpapi.NewToken()[:31] - attach, _ := json.Marshal(M{"uid": uid, "plan": planKey}) - signData, err := vp.BuildSignData(productID, int(price), outTradeNo, string(attach)) - if err != nil { - return c.Err(500, "生成支付参数失败") - } - var cid any - if cidv := c.CoupleID(); cidv != "" { - cid = cidv - } - if _, err := c.DB.Insert("INSERT INTO storage_orders (user_id, couple_id, out_trade_no, plan_key, product_id, amount_cents, env, status, attach) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)", - uid, cid, outTradeNo, planKey, productID, price, vp.Env, string(attach)); err != nil { - return err - } - return c.OK(M{ - "out_trade_no": outTradeNo, - "mode": storageSettingStr(c, settingPayMode, defaultPayMode), - "sign_data": signData, - "pay_sig": vp.PaySig(signData), - "signature": virtualpay.SessionSignature(user.Str("session_key"), signData), - // wx.requestVirtualPayment 所需的独立参数(与 signData 一致,供前端直接组装) - "offer_id": vp.OfferID, - "buy_quantity": 1, - "env": vp.Env, - "currency_type": "CNY", - "product_id": productID, - "goods_price": price, - }) -} - -// xpayNotify 道具发货推送(xpay_goods_deliver_notify)的 XML 消息结构。 -type xpayNotify struct { - ToUserName string `xml:"ToUserName"` - FromUserName string `xml:"FromUserName"` - CreateTime string `xml:"CreateTime"` - MsgType string `xml:"MsgType"` - Event string `xml:"Event"` - OpenId string `xml:"OpenId"` - OutTradeNo string `xml:"OutTradeNo"` - Env string `xml:"Env"` - WeChatPayInfo struct { - MchOrderNo string `xml:"MchOrderNo"` - } `xml:"WeChatPayInfo"` - GoodsInfo struct { - ProductId string `xml:"ProductId"` - Quantity string `xml:"Quantity"` - } `xml:"GoodsInfo"` -} - -func storagePayNotify(c *httpapi.Ctx) error { - token := c.App.Cfg.VirtualPay.NotifyToken - // 消息推送 URL 有效性验证(微信在后台配置时发起 GET 请求) - if c.Method == "GET" { - if token != "" && !virtualpay.VerifyNotifySignature(token, c.Q("timestamp"), c.Q("nonce"), c.Q("signature")) { - return c.Err(403, "签名验证失败") - } - return c.Raw(200, "text/plain; charset=utf-8", []byte(c.Q("echostr"))) - } - // 发货推送验签(明文安全模式) - if token != "" && !virtualpay.VerifyNotifySignature(token, c.Q("timestamp"), c.Q("nonce"), c.Q("signature")) { - return c.Err(403, "签名验证失败") - } - n, err := parseXpayNotify(c.Body()) - if err != nil || n.Event != "xpay_goods_deliver_notify" { - // 无法解析或非道具发货事件(退款/投诉/风控等):忽略内容,但仍应答成功,避免微信无限重试 - return notifyReply(c, 0, "success") - } - if n.OutTradeNo == "" { - return notifyReply(c, 0, "success") - } - if err := deliverStorageOrder(c.DB, n.OutTradeNo, n.WeChatPayInfo.MchOrderNo); err != nil { - // 发货失败返回非 0,微信将按策略重试(最多 15 次) - return notifyReply(c, 1, "deliver failed") - } - return notifyReply(c, 0, "success") -} - -// parseXpayNotify 解析微信发货推送的 XML 消息体(明文模式)。 -func parseXpayNotify(body []byte) (*xpayNotify, error) { - var n xpayNotify - if err := xml.Unmarshal(body, &n); err != nil { - return nil, err - } - return &n, nil -} - -// notifyReply 输出微信消息推送要求的应答:推送为 XML 时,响应也必须为 XML。 -func notifyReply(c *httpapi.Ctx, errCode int, errMsg string) error { - body := "" + store.ToStr(errCode) + "" - return c.Raw(200, "application/xml; charset=utf-8", []byte(body)) -} - -// deliverStorageOrder 按订单号发货:标记支付成功并为用户开通/续期会员。 -// 注意:该函数不直接向客户端写响应,而是返回 error 供调用方统一应答,避免污染微信回调的 XML 响应。 -func deliverStorageOrder(db *store.DB, outTradeNo, wxOrderID string) error { - 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) + if value := c.InTrim("expected_plan_version"); value != "" { + v, err := time.Parse(time.RFC3339Nano, value) + if err != nil || !v.Equal(plan.UpdatedAt) { + return c.Err(409, "套餐配置已变更,请刷新后重新购买") } } + return storageWechatOrderCreate(c, plan) + } func adminStorageSettings(c *httpapi.Ctx) error { if c.Method == "GET" { - return c.OK(M{ - "free_mb": storageSettingInt(c, settingFreeMB, defaultFreeMB), - "month_price_cents": storageSettingInt(c, settingMonthPrice, defaultMonthPrice), - "month_product_id": storageSettingStr(c, settingMonthProduct, ""), - "year_price_cents": storageSettingInt(c, settingYearPrice, defaultYearPrice), - "year_product_id": storageSettingStr(c, settingYearProduct, ""), - "pay_mode": storageSettingStr(c, settingPayMode, defaultPayMode), - "pay_env": int(storageSettingInt(c, settingPayEnv, int64(c.App.Cfg.VirtualPay.Env))), - "pay_offer_id": storagePaySetting(c, settingPayOfferID, c.App.Cfg.VirtualPay.OfferID), - "pay_app_key": storagePaySetting(c, settingPayAppKey, c.App.Cfg.VirtualPay.AppKey), - "pay_sandbox_key": storagePaySetting(c, settingPaySandboxKey, c.App.Cfg.VirtualPay.SandboxKey), + var plans []model.StoragePlan + var revision, free int64 + err := c.DB.ORM.Transaction(func(tx *gorm.DB) error { + if e := tx.Exec("SELECT pg_advisory_xact_lock(520002)").Error; e != nil { + return e + } + var e error + plans, e = storageCatalog(tx, false) + if e != nil { + return e + } + 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 { return err } - pairs := []struct { - k string - v any - }{ - {settingFreeMB, c.InInt("free_mb")}, - {settingMonthPrice, c.InInt("month_price_cents")}, - {settingMonthProduct, strings.TrimSpace(c.InStr("month_product_id"))}, - {settingYearPrice, c.InInt("year_price_cents")}, - {settingYearProduct, strings.TrimSpace(c.InStr("year_product_id"))}, - {settingPayMode, strings.TrimSpace(c.InStr("pay_mode"))}, - {settingPayOfferID, strings.TrimSpace(c.InStr("pay_offer_id"))}, - {settingPayAppKey, strings.TrimSpace(c.InStr("pay_app_key"))}, - {settingPaySandboxKey, strings.TrimSpace(c.InStr("pay_sandbox_key"))}, - {settingPayEnv, c.InInt("pay_env")}, - } - for _, p := range pairs { - if err := setSetting(c, p.k, p.v); err != nil { - return err - } - } - return c.OK(M{"success": true}) + return saveStorageCatalog(c) } // storagePlanLabel 套餐中文名(后台展示用) @@ -472,7 +239,7 @@ func adminStorageUsers(c *httpapi.Ctx) error { if !c.QHas("sort") { sortKey = "used_desc" } - sql := "SELECT id, user_id, username, nickname, avatar, gender, couple_id, created_at, last_login_at, storage_quota_mb, storage_plan, storage_plan_started_at, storage_vip_expire_at FROM users" + 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 params []any if vip == "vip" { @@ -512,7 +279,22 @@ func adminStorageUsers(c *httpapi.Ctx) error { quota = storageSettingInt(c, settingFreeMB, defaultFreeMB) } u["free_mb"] = quota - u["plan_label"] = storagePlanLabel(u.Str("storage_plan")) + u["plan_label"] = u.Str("storage_plan_name") + if u.Str("plan_label") == "" { + 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) } switch sortKey { @@ -541,73 +323,81 @@ func adminStorageUserUpdate(c *httpapi.Ctx) error { if err := c.RequirePOST(); err != nil { return err } - userID := c.InTrim("user_id") - if userID == "" { + uid := c.InTrim("user_id") + if !store.ValidUUID(uid) { return c.Err(400, "用户ID无效") } - user, _ := c.DB.QueryOne("SELECT id FROM users WHERE id = ?", userID) - if user == nil { - return c.Err(404, "用户不存在") + quota := c.InInt("quota_mb") + if quota < 0 || quota > 104857600 { + return c.Err(400, "容量无效") } - // 1) 自定义容量(0 = 恢复默认免费额度) - if c.InHas("quota_mb") { - quota := c.InInt("quota_mb") - if quota < 0 { - quota = 0 + 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 _, err := c.DB.Exec("UPDATE users SET storage_quota_mb = ? WHERE id = ?", quota, userID); err != nil { + 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 } - } - // 2) 套餐 / 到期时间 - if c.InHas("plan_key") { - planKey := c.InTrim("plan_key") - switch planKey { - case "month", "year": - months := 1 - if planKey == "year" { - months = 12 + if c.InHas("quota_mb") { + if err := tx.Model(&user).Update("storage_quota_mb", quota).Error; err != nil { + return err } - expire := c.InTrim("vip_expire_at") - if expire == "" { - if _, err := c.DB.Exec("UPDATE users SET storage_plan = ?, storage_plan_started_at = COALESCE(storage_plan_started_at, NOW()), storage_vip_expire_at = (COALESCE(storage_vip_expire_at, NOW()) + (? * INTERVAL '1 month')) WHERE id = ?", planKey, months, userID); err != nil { + } + if c.InHas("plan_key") { + if err := tx.Where("user_id=?", uid).Delete(&model.StorageEntitlement{}).Error; err != nil { + return err + } + if key == "free" { + return tx.Model(&user).Updates(map[string]any{"storage_plan": "", "storage_plan_name": "", "storage_plan_started_at": nil, "storage_vip_expire_at": nil}).Error + } + 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 } } 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 } } - case "free": - if _, err := c.DB.Exec("UPDATE users SET storage_plan = '', storage_plan_started_at = NULL, storage_vip_expire_at = NULL WHERE id = ?", userID); err != nil { - return err - } - default: - return c.Err(400, "无效的套餐") - } - } else if c.InHas("vip_expire_at") { - expire := c.InTrim("vip_expire_at") - if expire == "" { - if _, err := c.DB.Exec("UPDATE users SET storage_vip_expire_at = NULL WHERE id = ?", userID); err != nil { - return err - } - } else { - if _, err := c.DB.Exec("UPDATE users SET storage_vip_expire_at = ? WHERE id = ?", expire, userID); err != nil { - return err - } + return tx.Model(&user).Update("storage_vip_expire_at", expiry).Error } + return nil + }) + if err != nil { + return err } return c.OK(M{"success": true}) } -// envLabel 支付环境中文名。 -func envLabel(env int64) string { - if env == 0 { - return "正式" - } - return "沙箱" -} - // adminStorageOrders 存储支付记录查询:支持按订单号/用户ID/昵称搜索、按状态筛选、分页。 func adminStorageOrders(c *httpapi.Ctx) error { search := strings.TrimSpace(c.Q("search")) @@ -622,17 +412,21 @@ func adminStorageOrders(c *httpapi.Ctx) error { params = append(params, status) } if search != "" { - if isNumeric(search) { - where = append(where, "(o.out_trade_no = ? OR o.user_id = ? OR u.user_id = ?)") - params = append(params, search, store.ToInt(search), store.ToInt(search)) + like := "%" + search + "%" + if store.ValidUUID(search) { + 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 { - like := "%" + search + "%" - where = append(where, "(u.nickname LIKE ? OR u.username LIKE ?)") - params = append(params, like, like) + 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, - 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` if len(where) > 0 { sql += " WHERE " + strings.Join(where, " AND ") @@ -643,8 +437,10 @@ func adminStorageOrders(c *httpapi.Ctx) error { return err } for _, o := range rs { - o["plan_label"] = storagePlanLabel(o.Str("plan_key")) - o["env_label"] = envLabel(o.Int("env")) + o["plan_label"] = o.Str("plan_name") + if o.Str("plan_label") == "" { + o["plan_label"] = storagePlanLabel(o.Str("plan_key")) + } } return c.OK(paged(c, rs)) } diff --git a/past-go/internal/handler/storage_calendar.go b/past-go/internal/handler/storage_calendar.go new file mode 100644 index 0000000..2ab490b --- /dev/null +++ b/past-go/internal/handler/storage_calendar.go @@ -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()) +} diff --git a/past-go/internal/handler/storage_calendar_test.go b/past-go/internal/handler/storage_calendar_test.go new file mode 100644 index 0000000..6533772 --- /dev/null +++ b/past-go/internal/handler/storage_calendar_test.go @@ -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) + } + } +} diff --git a/past-go/internal/handler/storage_plans.go b/past-go/internal/handler/storage_plans.go new file mode 100644 index 0000000..f6abb60 --- /dev/null +++ b/past-go/internal/handler/storage_plans.go @@ -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}) +} diff --git a/past-go/internal/handler/storage_plans_test.go b/past-go/internal/handler/storage_plans_test.go new file mode 100644 index 0000000..a4d58d6 --- /dev/null +++ b/past-go/internal/handler/storage_plans_test.go @@ -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") +} diff --git a/past-go/internal/handler/wechat_pay.go b/past-go/internal/handler/wechat_pay.go new file mode 100644 index 0000000..647f418 --- /dev/null +++ b/past-go/internal/handler/wechat_pay.go @@ -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: + } + } +} diff --git a/past-go/internal/handler/wechat_pay_test.go b/past-go/internal/handler/wechat_pay_test.go new file mode 100644 index 0000000..864b9b3 --- /dev/null +++ b/past-go/internal/handler/wechat_pay_test.go @@ -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")) +} diff --git a/past-go/internal/httpapi/ctx.go b/past-go/internal/httpapi/ctx.go index 8d1b711..1599288 100644 --- a/past-go/internal/httpapi/ctx.go +++ b/past-go/internal/httpapi/ctx.go @@ -18,16 +18,18 @@ import ( "past/internal/store" "past/internal/upload" "past/internal/wechat" + "past/internal/wxpay" ) // App 全局依赖 type App struct { - Cfg *config.Config - DB *store.DB - Wechat *wechat.Client - Geo *geo.Client - Files *upload.Uploader - Admin *AdminSessions + WechatPay wxpay.Gateway + Cfg *config.Config + DB *store.DB + Wechat *wechat.Client + Geo *geo.Client + Files *upload.Uploader + Admin *AdminSessions } // Ctx 单次请求上下文,对应 PHP 中的全局变量 $_GET/$_POST/$input/$_FILES 与 getUserId()。 diff --git a/past-go/internal/httpapi/media_test.go b/past-go/internal/httpapi/media_test.go index a2579c7..1fa9d50 100644 --- a/past-go/internal/httpapi/media_test.go +++ b/past-go/internal/httpapi/media_test.go @@ -39,7 +39,7 @@ func TestPublishedUnsignedPhotoURLsStillLoad(t *testing.T) { } func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) { 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"} normalized := normalizeMediaInput(input).(map[string]any) if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" { diff --git a/past-go/internal/httpapi/router.go b/past-go/internal/httpapi/router.go index 2f92896..4fbad99 100644 --- a/past-go/internal/httpapi/router.go +++ b/past-go/internal/httpapi/router.go @@ -3,9 +3,9 @@ package httpapi import ( "bytes" "fmt" + "go.uber.org/zap" "io" "io/fs" - "log/slog" "net/http" "net/url" "os" @@ -55,7 +55,7 @@ func (r *Router) Actions() []string { // 与 index.php 中 $publicActions / $adminActions 完全一致 var publicActions = set("register", "login", "wechat_login", "site_info", "site_stats", "admin_login", - "check_invite_code", "register_by_invite", "check_auth", "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") @@ -77,7 +77,12 @@ func (r *Router) Engine(debugMode bool) *gin.Engine { gin.SetMode(gin.ReleaseMode) } 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.MaxMultipartMemory = 32 << 20 @@ -164,7 +169,7 @@ func (r *Router) serveAPI(g *gin.Context) { defer func() { 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 { c.Err(500, fmt.Sprint(rec)) } @@ -204,7 +209,7 @@ func (r *Router) serveAPI(g *gin.Context) { return } 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()) } if !c.responded { @@ -213,7 +218,7 @@ func (r *Router) serveAPI(g *gin.Context) { } } -// accessLog gin 中间件:结构化访问日志(slog) +// accessLog gin 中间件:结构化访问日志(zap) func accessLog() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() @@ -225,7 +230,7 @@ func accessLog() gin.HandlerFunc { if uid, ok := c.Get("uid"); ok { attrs = append(attrs, "uid", uid) } - slog.Info("http", attrs...) + zap.S().Infow("http", attrs...) } } diff --git a/past-go/internal/logging/logging.go b/past-go/internal/logging/logging.go new file mode 100644 index 0000000..93517e6 --- /dev/null +++ b/past-go/internal/logging/logging.go @@ -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 +} diff --git a/past-go/internal/logging/logging_test.go b/past-go/internal/logging/logging_test.go new file mode 100644 index 0000000..a759654 --- /dev/null +++ b/past-go/internal/logging/logging_test.go @@ -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) + } +} diff --git a/past-go/internal/model/models.go b/past-go/internal/model/models.go index 93ab14f..a8627d7 100644 --- a/past-go/internal/model/models.go +++ b/past-go/internal/model/models.go @@ -45,7 +45,8 @@ type User struct { 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"` 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"` 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"` @@ -116,7 +117,7 @@ type Timeline struct { type Album struct { 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"` Description string `gorm:"column:description;type:text" json:"description"` 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"` } -type StorageOrder struct { +type StoragePlan struct { 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"` 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"` - PlanKey string `gorm:"column:plan_key;type:varchar(20);not null" json:"plan_key"` - ProductId string `gorm:"column:product_id;type:varchar(64);not null;default:''" json:"product_id"` + PlanKey string `gorm:"column:plan_key;type:varchar(64);not null" json:"plan_key"` 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"` 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"` @@ -465,10 +497,10 @@ type SmokingEntry struct { // Models is the complete AutoMigrate registry. 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. 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{}} } diff --git a/past-go/internal/notify/notify.go b/past-go/internal/notify/notify.go index a37cf63..d0dfdfb 100644 --- a/past-go/internal/notify/notify.go +++ b/past-go/internal/notify/notify.go @@ -3,7 +3,7 @@ package notify import ( - "log/slog" + "go.uber.org/zap" "strings" "time" @@ -54,7 +54,7 @@ func Run(db *store.DB, wx *wechat.Client, interval time.Duration) { func() { defer func() { if r := recover(); r != nil { - slog.Error("cron_notify panic", "err", r) + zap.S().Errorw("cron_notify panic", "err", r) } }() Once(db, wx, false) @@ -91,7 +91,7 @@ func Once(db *store.DB, wx Sender, force bool) { args = append(args, maxBatch) activities, err := db.Query(query, args...) if err != nil { - slog.Error("cron_notify 查询失败", "err", err) + zap.S().Errorw("cron_notify 查询失败", "err", err) return } processed, success, fail, skip := 0, 0, 0, 0 @@ -146,5 +146,5 @@ func Once(db *store.DB, wx Sender, force bool) { 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) } diff --git a/past-go/internal/store/files.go b/past-go/internal/store/files.go index dbdc21f..505a5a9 100644 --- a/past-go/internal/store/files.go +++ b/past-go/internal/store/files.go @@ -320,32 +320,14 @@ func CheckFileQuota(db *gorm.DB, userId string, file model.File) error { return nil } } - var vip int64 - if err := db.Model(&model.User{}).Where("id IN ? AND storage_vip_expire_at>NOW()", owners).Count(&vip).Error; err != nil { + allowance, err := StorageSpace(db, userId) + if err != nil { return err } - if vip > 0 { + if allowance.Unlimited { return nil } - var user model.User - 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 - } - } - } + limitMB := allowance.QuotaMb used, err := UsedFileBytes(db, owners) if err != nil { return err diff --git a/past-go/internal/store/relationships.go b/past-go/internal/store/relationships.go index 217cfae..6e1824c 100644 --- a/past-go/internal/store/relationships.go +++ b/past-go/internal/store/relationships.go @@ -3,7 +3,7 @@ package store import ( "crypto/sha256" "fmt" - "log/slog" + "go.uber.org/zap" "strings" "time" ) @@ -59,7 +59,7 @@ func (db *DB) finishUnbinding(cid string) error { func (db *DB) RunUnbindingWorker() { run := func() { if err := db.AdvanceUnbindings(); err != nil { - slog.Error("解绑到期处理失败", "err", err) + zap.S().Errorw("解绑到期处理失败", "err", err) } } diff --git a/past-go/internal/store/schema.go b/past-go/internal/store/schema.go index 2e603d5..b2da3f3 100644 --- a/past-go/internal/store/schema.go +++ b/past-go/internal/store/schema.go @@ -3,6 +3,7 @@ package store import ( "fmt" "strings" + "time" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -63,11 +64,11 @@ func (db *DB) InitSchema() error { return err } if count == 0 { - hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte("past"), bcrypt.DefaultCost) if err != nil { 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 { return err } @@ -78,6 +79,10 @@ func (db *DB) InitSchema() error { return err } } + + if err := initStoragePlans(tx); err != nil { + return err + } // 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 { return err @@ -85,3 +90,65 @@ func (db *DB) InitSchema() error { 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 +} diff --git a/past-go/internal/store/storage.go b/past-go/internal/store/storage.go new file mode 100644 index 0000000..8a9ba46 --- /dev/null +++ b/past-go/internal/store/storage.go @@ -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 +} diff --git a/past-go/internal/virtualpay/virtualpay.go b/past-go/internal/virtualpay/virtualpay.go deleted file mode 100644 index 0596484..0000000 --- a/past-go/internal/virtualpay/virtualpay.go +++ /dev/null @@ -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) -} diff --git a/past-go/internal/wechat/subscribe_test.go b/past-go/internal/wechat/subscribe_test.go index ae65d68..dfa5c06 100644 --- a/past-go/internal/wechat/subscribe_test.go +++ b/past-go/internal/wechat/subscribe_test.go @@ -1,17 +1,17 @@ package wechat import ( + "context" "database/sql/driver" "encoding/json" "fmt" "io" "net/http" - "os" "past/internal/store" "past/internal/testdb" - "path/filepath" "strings" "testing" + "time" ) type subscribeTransport func(*http.Request) (*http.Response, error) @@ -80,9 +80,8 @@ func TestSubscribeRecordsEveryOutcome(t *testing.T) { 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"}) db, _ := testdb.Open(t, steps) - client := New("app", "secret", tt.template, t.TempDir(), store.WrapSQL(db)) - os.MkdirAll(filepath.Dir(client.cacheFile()), 0755) - os.WriteFile(client.cacheFile(), []byte("cached-token"), 0600) + client := New("app", "secret", tt.template, testRedis(t), "test:", store.WrapSQL(db)) + client.Redis.Set(context.Background(), client.tokenKey(), "cached-token", time.Hour) calls := 0 client.http.Transport = subscribeTransport(func(r *http.Request) (*http.Response, error) { calls++ @@ -118,7 +117,7 @@ func TestSubscribeWithoutActivityStillRecords(t *testing.T) { 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, "", "", "") { t.Fatal("unsubscribed send succeeded") } @@ -126,7 +125,7 @@ func TestSubscribeWithoutActivityStillRecords(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"}}}}) - 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 }) if !client.SendSubscribeMessage("2", "notify", nil, "", "10", "1") { t.Fatal("completed message must be treated as success") diff --git a/past-go/internal/wechat/token.go b/past-go/internal/wechat/token.go new file mode 100644 index 0000000..c3a1f96 --- /dev/null +++ b/past-go/internal/wechat/token.go @@ -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)) + } +} diff --git a/past-go/internal/wechat/token_test.go b/past-go/internal/wechat/token_test.go new file mode 100644 index 0000000..c2a1f82 --- /dev/null +++ b/past-go/internal/wechat/token_test.go @@ -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") + } +} diff --git a/past-go/internal/wechat/wechat.go b/past-go/internal/wechat/wechat.go index c6c6a8d..01c3c33 100644 --- a/past-go/internal/wechat/wechat.go +++ b/past-go/internal/wechat/wechat.go @@ -5,9 +5,10 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" "hash/fnv" "io" - "log/slog" "mime/multipart" "net/http" "net/url" @@ -24,7 +25,8 @@ type Client struct { AppID string Secret string NotifyTemplateID string - DataDir string // 用于 access_token.cache + Redis redis.UniversalClient + KeyPrefix string DB *store.DB http *http.Client @@ -32,9 +34,9 @@ type Client struct { 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{ - 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}, } } @@ -44,41 +46,6 @@ func (c *Client) Templates() map[string]string { 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 func (c *Client) postJSON(u string, body []byte, timeout time.Duration) map[string]any { cl := &http.Client{Timeout: timeout} @@ -197,7 +164,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data 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) if err != nil { - slog.Error("读取通知去重状态失败", "err", err) + zap.S().Errorw("读取通知去重状态失败", "err", err) return false } if previous != nil && previous.Str("status") == "success" { @@ -261,7 +228,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data if tok == "" { lastError = "获取access_token失败" if attempt < 2 { - c.clearTokenCache() + c.clearTokenCache(tok) } continue } @@ -275,7 +242,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data lastError += ": " + uerr.Err.Error() } if attempt < 2 { - c.clearTokenCache() + c.clearTokenCache(tok) time.Sleep(200 * time.Millisecond) } continue @@ -285,7 +252,7 @@ func (c *Client) SendSubscribeMessage(toUserID string, templateType string, data resp.Body.Close() lastError = fmt.Sprintf("微信接口HTTP %d", resp.StatusCode) if attempt < 2 { - c.clearTokenCache() + c.clearTokenCache(tok) time.Sleep(200 * time.Millisecond) } 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) if (ec == 40001 || ec == 40014 || ec == 42001) && attempt < 2 { - c.clearTokenCache() + c.clearTokenCache(tok) continue } // 即使微信返回“用户拒收/一次性订阅次数用尽”(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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 { - 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) } if err != nil { - slog.Error("logNotifyResult error", "err", err) + zap.S().Errorw("logNotifyResult error", "err", err) } } diff --git a/past-go/internal/wxpay/client.go b/past-go/internal/wxpay/client.go new file mode 100644 index 0000000..f5a72a8 --- /dev/null +++ b/past-go/internal/wxpay/client.go @@ -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 +} diff --git a/past-go/internal/wxpay/client_test.go b/past-go/internal/wxpay/client_test.go new file mode 100644 index 0000000..8d86ca9 --- /dev/null +++ b/past-go/internal/wxpay/client_test.go @@ -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") + } +} diff --git a/past-go/web/admin/src/components/StoragePlanEditor.tsx b/past-go/web/admin/src/components/StoragePlanEditor.tsx new file mode 100644 index 0000000..99e0d33 --- /dev/null +++ b/past-go/web/admin/src/components/StoragePlanEditor.tsx @@ -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)=>onChange(plans.map((p,i)=>i===index?{...p,...patch}:p)) + return
+

容量套餐({plans.length} 档)

+

容量为总可用容量,0 表示无限;1024MB = 1GB。不同档位取当前有效的最大容量,不累加;同容量续费顺延,不同容量分别到期。变更和下架不影响已购买权益。

+ {plans.map((p,i)=>
+
套餐 {i+1}
+
+
update(i,{name:e.target.value})} placeholder="例如:5GB 季度套餐"/>
+
update(i,{quota_mb:Number(e.target.value)})}/>
+
update(i,{months:Number(e.target.value)})}/>
+
update(i,{price_cents:Math.round(Number(e.target.value)*100)})}/>
+
update(i,{sort_order:Number(e.target.value)})}/>
+
+
)} + {plans.length===0 &&

暂无套餐,保存后小程序将停止展示可购买套餐。

} +
+} diff --git a/past-go/web/admin/src/lib/api.ts b/past-go/web/admin/src/lib/api.ts index bcee402..de77f5d 100644 --- a/past-go/web/admin/src/lib/api.ts +++ b/past-go/web/admin/src/lib/api.ts @@ -20,7 +20,7 @@ function mapKeys(value: any, name: (key: string) => string): any { const toWire = (value: unknown) => mapKeys(value, camelName) const fromWire = (value: unknown) => mapKeys(value, snakeName) -type Params = Record +type Params = Record export async function api(action: string, data?: Params | null, method: "GET" | "POST" = "POST"): Promise { let url = `${API_BASE}/${encodeURIComponent(camelName(action))}` diff --git a/past-go/web/admin/src/pages/Settings.tsx b/past-go/web/admin/src/pages/Settings.tsx index 7711b3e..d2c06b9 100644 --- a/past-go/web/admin/src/pages/Settings.tsx +++ b/past-go/web/admin/src/pages/Settings.tsx @@ -25,7 +25,7 @@ export default function SettingsPage() { setUsername("") }) 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 }) toast.success("密码已更新") setOldPw(""); setNewPw("") @@ -54,7 +54,7 @@ export default function SettingsPage() { 修改密码
setOldPw(e.target.value)} />
-
setNewPw(e.target.value)} placeholder="6位以上" />
+
setNewPw(e.target.value)} placeholder="4位以上" />
diff --git a/past-go/web/admin/src/pages/Storage.tsx b/past-go/web/admin/src/pages/Storage.tsx index bd16e57..81445df 100644 --- a/past-go/web/admin/src/pages/Storage.tsx +++ b/past-go/web/admin/src/pages/Storage.tsx @@ -1,3 +1,4 @@ +import { StoragePlanEditor, type StoragePlan } from "@/components/StoragePlanEditor" import { useEffect, useState } from "react" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Search, X, Crown } from "lucide-react" @@ -37,7 +38,9 @@ interface StorageUser { used_bytes: number free_mb: number storage_quota_mb: number - storage_vip: boolean + quota_mb:number + is_unlimited:boolean + storage_vip: boolean storage_plan: string plan_label: string storage_plan_started_at: string @@ -59,6 +62,7 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on const [quota, setQuota] = useState("") const [plan, setPlan] = useState("free") const [expire, setExpire] = useState("") + const catalog=useQuery({queryKey:["storage_plan_catalog"],queryFn:()=>api("adminStorageSettings",null,"GET"),enabled:!!user}) useEffect(() => { if (!user) return @@ -109,13 +113,13 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on
- +
@@ -124,7 +128,7 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on
setExpire(e.target.value)} /> -

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

+

按所选套餐有效月数计算;手动设置会替换该用户原有套餐权益。

)} @@ -140,16 +144,12 @@ function StorageUserEditDialog({ user, onClose }: { user: StorageUser | null; on } interface StorageSettingsResp { + plans:StoragePlan[] + revision:number + pay_provider: string + wechat_pay_ready: boolean + wechat_pay_mch_id: string 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 { @@ -161,10 +161,8 @@ interface StorageOrder { out_trade_no: string plan_key: string plan_label: string - product_id: string amount_cents: number - env: number - env_label: string + pay_provider: string status: string wx_order_id: string created_at: string @@ -185,6 +183,7 @@ function orderStatusBadge(status: string) { } export default function StoragePage() { + const qc=useQueryClient() // 用户管理 const [input, setInput] = useState("") const [search, setSearch] = useState("") @@ -229,58 +228,42 @@ export default function StoragePage() { // 收费设置 const [freeMb, setFreeMb] = useState("200") - const [monthPrice, setMonthPrice] = useState("5") - const [monthProductId, setMonthProductId] = useState("") - const [yearPrice, setYearPrice] = useState("29.9") - const [yearProductId, setYearProductId] = useState("") - const [payMode, setPayMode] = useState("short_series_goods") - const [payEnv, setPayEnv] = useState(1) - const [payOfferId, setPayOfferId] = useState("") - const [payAppKey, setPayAppKey] = useState("") - const [paySandboxKey, setPaySandboxKey] = useState("") + const [plans,setPlans]=useState([]) + const [revision,setRevision]=useState(0) + const [savingFee,setSavingFee]=useState(false) + const [wechatReady, setWechatReady] = useState(false) + const [wechatMch, setWechatMch] = useState("") const [feeLoading, setFeeLoading] = useState(true) + const [feeLoaded,setFeeLoaded]=useState(false) useEffect(() => { api("adminStorageSettings", null, "GET") .then((d) => { + setWechatReady(d.wechat_pay_ready) + setWechatMch(d.wechat_pay_mch_id || "") setFreeMb(String(d.free_mb ?? 200)) - setMonthPrice((d.month_price_cents / 100).toString()) - setMonthProductId(d.month_product_id || "") - setYearPrice((d.year_price_cents / 100).toString()) - setYearProductId(d.year_product_id || "") - setPayMode(d.pay_mode || "short_series_goods") - setPayEnv(d.pay_env ?? 1) - setPayOfferId(d.pay_offer_id || "") - setPayAppKey(d.pay_app_key || "") - setPaySandboxKey(d.pay_sandbox_key || "") + setPlans(d.plans) + setRevision(d.revision) + setFeeLoaded(true) }) .catch((e) => toast.error((e as Error).message)) .finally(() => setFeeLoading(false)) }, []) async function saveFee() { - const monthCents = Math.round(parseFloat(monthPrice || "0") * 100) - const yearCents = Math.round(parseFloat(yearPrice || "0") * 100) - const freeMbNum = parseInt(freeMb || "0", 10) - if (freeMbNum <= 0) return toast.error("免费额度需大于0") - if (monthCents <= 0 || yearCents <= 0) return toast.error("套餐价格需大于0") - try { - await api("adminStorageSettings", { - free_mb: freeMbNum, - month_price_cents: monthCents, - month_product_id: monthProductId.trim(), - year_price_cents: yearCents, - year_product_id: yearProductId.trim(), - pay_mode: payMode.trim() || "short_series_goods", - pay_offer_id: payOfferId.trim(), - pay_app_key: payAppKey.trim(), - pay_sandbox_key: paySandboxKey.trim(), - pay_env: payEnv, - }) - toast.success("存储收费配置已保存") - } catch (e) { - toast.error((e as Error).message) + const freeMbNum=Number(freeMb) + if (!Number.isInteger(freeMbNum) || freeMbNum<0 || freeMbNum>104857600) return toast.error("免费容量无效") + for (const p of plans) { + 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("请填写有效的套餐名称、容量、月数、价格和排序") } + setSavingFee(true) + try { + await api("adminStorageSettings",{free_mb:freeMbNum,plans,revision}) + const saved=await api("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 ( @@ -371,7 +354,7 @@ export default function StoragePage() { {formatBytes(u.used_bytes)} - {u.storage_vip ? ♾️ 无限 : `${u.free_mb} MB`} + {u.is_unlimited ? ♾️ 无限 : formatBytes(u.quota_mb*1024*1024)} {u.storage_vip ? {u.plan_label || "会员"} : 免费用户} @@ -408,42 +391,15 @@ export default function StoragePage() {
-
- - -
-
setPayOfferId(e.target.value)} placeholder="微信虚拟支付后台的 OfferID" />
-
setPayAppKey(e.target.value)} placeholder="正式环境 AppKey(上线后填写)" />
-
setPaySandboxKey(e.target.value)} placeholder="沙箱环境 AppKey" />
-

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

+ +

{wechatReady ? "已启用小程序支付" : "支付服务尚未就绪"}

+

商户号:{wechatMch || "未配置"}

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

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

-
+
- +
@@ -486,7 +442,7 @@ export default function StoragePage() { 订单号 套餐 金额 - 环境 + 支付方式 状态 微信订单号 创建时间 @@ -507,7 +463,7 @@ export default function StoragePage() { {o.out_trade_no} {o.plan_label || "-"} {formatAmount(o.amount_cents)} - {o.env_label} + {o.pay_provider === "wechat" ? "微信支付" : "历史订单"} {orderStatusBadge(o.status)} {o.wx_order_id || "-"} {fmtTime(o.created_at)} diff --git a/past-go/web/admin/tests/media.test.mjs b/past-go/web/admin/tests/media.test.mjs index a7d2523..2f73111 100644 --- a/past-go/web/admin/tests/media.test.mjs +++ b/past-go/web/admin/tests/media.test.mjs @@ -7,8 +7,8 @@ test('file UUID uses the stable API endpoint for thumbnails, original and copied const access = '/api/files/' + id assert.equal(mediaUrl(id), 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(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) }) diff --git a/weapp/config/env.js b/weapp/config/env.js index 41c0533..32408ab 100644 --- a/weapp/config/env.js +++ b/weapp/config/env.js @@ -1,8 +1,8 @@ // 开发工具使用本机服务;真机调试时把 dev.apiBase 改为电脑的局域网 IP。 const environments = { dev: { apiBase: 'http://127.0.0.1:8080/api' }, - trial: { apiBase: 'https://past.sundynix.cn/api' }, - release: { apiBase: 'https://past.sundynix.cn/api' } + trial: { apiBase: 'https://we.sundynix.cn/api' }, + release: { apiBase: 'https://we.sundynix.cn/api' } }; let version = 'release'; try { diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js index 3f6fc9e..c964ba7 100644 --- a/weapp/pages/album/album.js +++ b/weapp/pages/album/album.js @@ -138,7 +138,7 @@ Page(withDataCache(withListPagination({ if (!ticket) return; try { var self = this; - var albumId = this.data.albumId; + var albumId = /^-\d+$/.test(String(this.data.albumId)) ? null : this.data.albumId; if (!albumId) { var albums = itemsOf(await api.albumList({page:1,page_size:1})); if (albums && albums.length > 0) { @@ -322,18 +322,11 @@ Page(withDataCache(withListPagination({ await requestSubscribeAfterAction('photo'); try { - // 上传前先预热 session,确保认证状态有效 - // 解决 Android 端首次上传时 session 未初始化的问题 - try { - wx.showLoading({ title: '准备中...', mask: true }); - await api.checkAuth(); - wx.hideLoading(); - } catch (e) { - wx.hideLoading(); - console.warn('Session 预热失败(不影响后续上传)', e); + var albumId = /^-\d+$/.test(String(this.data.albumId)) ? null : this.data.albumId; + if (!albumId) { + const albums = itemsOf(await api.albumList({ page: 1, page_size: 1 })); + albumId = albums.length ? albums[0].id : null; } - - var albumId = this.data.albumId; if (!albumId) { wx.showLoading({ title: '创建相册...' }); var album = await api.albumAdd({ title: '默认相册', description: '我们的甜蜜照片' }); @@ -341,6 +334,7 @@ Page(withDataCache(withListPagination({ this.setData({ albumId: albumId }); wx.hideLoading(); } + this.setData({ albumId: albumId }); var batchId = Date.now().toString(); // 自动定位:为未填写位置的照片自动获取地理位置(发布后自动显示) diff --git a/weapp/pages/profile/profile.js b/weapp/pages/profile/profile.js index d8f2bdc..38e4035 100644 --- a/weapp/pages/profile/profile.js +++ b/weapp/pages/profile/profile.js @@ -1036,7 +1036,7 @@ Page(withDataCache({ loadStorageInfo: async function() { try { 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 isVip = !!(d.is_vip || d.isVip); const usedText = formatStorageBytes(usedBytes); @@ -1045,11 +1045,11 @@ Page(withDataCache({ const expire = d.vip_expire_at || d.vipExpireAt; this.setData({ storageVip: true, - storageText: '无限空间', + storageText: (d.is_unlimited || d.isUnlimited) ? '无限空间' : formatStorageBytes(freeBytes), storagePercent: 0, - storageOverQuota: false, + storageOverQuota: !(d.is_unlimited || d.isUnlimited) && usedBytes>freeBytes, storageUsedText: usedText, - storageExpireText: expire ? ('有效期至 ' + String(expire).slice(0, 10)) : '已开通无限空间' + storageExpireText: expire ? ('有效期至 ' + String(expire).slice(0, 10)) : '已开通容量套餐' }); } else { const percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0; diff --git a/weapp/pages/profile/profile.wxml b/weapp/pages/profile/profile.wxml index 5cce863..df2166c 100644 --- a/weapp/pages/profile/profile.wxml +++ b/weapp/pages/profile/profile.wxml @@ -13,7 +13,7 @@ 存储空间 - 👑 无限空间 + 👑 {{storageText}} 已用 {{storageUsedText}}{{storageExpireText ? ' · ' + storageExpireText : ''}} @@ -28,7 +28,7 @@ {{storageOverQuota ? '已超出免费空间,升级后可继续上传' : '已用 ' + storagePercent + '% 免费空间'}} - {{storageOverQuota ? '升级无限空间 ›' : '查看 ›'}} + {{storageOverQuota ? '扩容 ›' : '查看 ›'}} diff --git a/weapp/pages/storage/storage.js b/weapp/pages/storage/storage.js index 16cf167..dd2e821 100644 --- a/weapp/pages/storage/storage.js +++ b/weapp/pages/storage/storage.js @@ -21,12 +21,15 @@ Page({ percent: 0, overQuota: false, isVip: false, + isUnlimited:false, + quotaText:"200 MB", vipExpireText: '', plans: [], paying: false, }, - onLoad: function () { this.setData(getLayout()); }, + onLoad: function () { this._unloaded = false; this.setData(getLayout()); }, + onUnload: function () { this._unloaded = true; }, onShow: function () { this.loadInfo(); }, onPullDownRefresh: function () { this.loadInfo().then(function () { wx.stopPullDownRefresh(); }); }, @@ -34,7 +37,8 @@ Page({ var self = this; this.setData({ loading: true, error: false }); 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 percent = freeBytes > 0 ? Math.min(100, Math.round(usedBytes / freeBytes * 100)) : 0; var overQuota = usedBytes > freeBytes; @@ -43,25 +47,30 @@ Page({ return { key: p.key, name: p.name, - priceText: (cents / 100).toFixed(cents % 100 === 0 ? 0 : 1), - unit: p.key === 'year' ? '年' : '月', - productId: p.product_id || p.productId, + priceCents:cents, + version:p.updated_at || p.updatedAt, + 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 = ''; if (d.is_vip || d.isVip) { var expire = d.vip_expire_at || d.vipExpireAt ? String(d.vip_expire_at || d.vipExpireAt).slice(0, 10) : ''; - vipExpireText = expire ? ('有效期至 ' + expire) : '已开通无限空间'; + vipExpireText = expire ? ('有效期至 ' + expire) : '已开通容量套餐'; } self.setData({ loading: false, usedBytes: 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), percent: percent, overQuota: overQuota, isVip: !!(d.is_vip || d.isVip), + isUnlimited:unlimited, + quotaText:unlimited?"无限空间":formatBytes(freeBytes), vipExpireText: vipExpireText, plans: plans, }); @@ -83,14 +92,14 @@ Page({ wx.showLoading({ title: '下单中...', mask: 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(); - if (!order || !(order.sign_data || order.signData)) { - self.setData({ paying: false }); - wx.showToast({ title: '下单失败', icon: 'none' }); + if (order && order.pay_provider === 'wechat' && order.payment) { + self.callWechatPay(order); return; } - self.callVirtualPay(order); + self.setData({ paying: false }); + wx.showToast({ title: '支付参数不完整,请重新下单', icon: 'none' }); }).catch(function (err) { wx.hideLoading(); self.setData({ paying: false }); @@ -98,37 +107,54 @@ Page({ }); }, - callVirtualPay: function (order) { + callWechatPay: function (order) { var self = this; - if (!wx.requestVirtualPayment) { + var payment = order.payment; + if (!wx.requestPayment) { this.setData({ paying: false }); - wx.showModal({ - title: '版本过低', - content: '请升级微信至最新版本后再试。', - showCancel: false - }); + wx.showToast({ title: '当前环境不支持微信支付,请使用微信真机', icon: 'none' }); return; } - // wx.requestVirtualPayment 仅接受 signData / paySig / signature / mode 四个参数; - // offerId、env、productId、goodsPrice 等均已编码在 signData JSON 字符串内,不可再平铺为顶层参数。 - wx.requestVirtualPayment({ - signData: order.sign_data || order.signData, - paySig: order.pay_sig || order.paySig, - signature: order.signature, - mode: order.mode || 'short_series_goods', - success: function () { - self.setData({ paying: false }); - wx.showToast({ title: '支付成功', icon: 'success' }); - setTimeout(function () { self.loadInfo(); }, 1200); - }, - fail: function (err) { - self.setData({ paying: false }); - var msg = '支付未完成'; - var m = (err && err.errMsg) || ''; - if (m.indexOf('cancel') >= 0) msg = '已取消支付'; - else if (m.indexOf('fail') >= 0) msg = '支付失败,请稍后重试'; - wx.showToast({ title: msg, icon: 'none' }); + wx.requestPayment({ + timeStamp: payment.time_stamp, + nonceStr: payment.nonce_str, + package: payment.package, + signType: payment.sign_type, + paySign: payment.pay_sign, + success: function () { return self.confirmWechatPayment(order.out_trade_no); }, + fail: function (error) { + // Even a cashier error can occur after payment; query the server once. + var message = String(error && error.errMsg || '').indexOf('cancel') >= 0 ? '已取消支付' : '支付未完成'; + return self.confirmWechatPayment(order.out_trade_no, message); } }); }, + + 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 }); + } + }, + }); diff --git a/weapp/pages/storage/storage.wxml b/weapp/pages/storage/storage.wxml index 20a1924..8d01903 100644 --- a/weapp/pages/storage/storage.wxml +++ b/weapp/pages/storage/storage.wxml @@ -9,27 +9,27 @@ 已用空间 - 👑 无限空间 + 👑 {{quotaText}} {{usedText}} - / {{freeText}} 免费 + / {{quotaText}} - + {{vipExpireText}} 已超出免费空间,升级后即可继续上传 - 已使用 {{percent}}% 免费空间 + 已使用 {{percent}}% 存储空间 - 升级无限空间 + 选择容量套餐 {{item.name}} - 无限存储空间,照片视频想存就存 + {{item.description}} ¥{{item.priceText}} @@ -38,12 +38,13 @@ + 暂无上架套餐 📌 说明 • 默认提供 {{freeMb}}MB 免费空间 - • 开通会员后,照片、视频等不再限制存储空间 - • 会员到期后,已上传内容仍可正常查看 + • 套餐容量为总可用容量,不与免费额度叠加;情侣共享当前有效的最大容量 + • 套餐到期后已上传内容仍可查看,超出可用容量将暂停新增上传 diff --git a/weapp/tests/album.test.cjs b/weapp/tests/album.test.cjs index 92d65df..31b8793 100644 --- a/weapp/tests/album.test.cjs +++ b/weapp/tests/album.test.cjs @@ -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[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'); +}); diff --git a/weapp/tests/env.test.cjs b/weapp/tests/env.test.cjs index d40459a..ea0b51e 100644 --- a/weapp/tests/env.test.cjs +++ b/weapp/tests/env.test.cjs @@ -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 {API_BASE,getImageUrl} = load(path.join(__dirname,'../utils/request.js')); 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'); } }); diff --git a/weapp/tests/file-media.test.cjs b/weapp/tests/file-media.test.cjs index ea145c0..5793032 100644 --- a/weapp/tests/file-media.test.cjs +++ b/weapp/tests/file-media.test.cjs @@ -15,7 +15,7 @@ function client(extra = {}) { } test('business requests persist IDs, preserve order and distinguish clear from omitted fields', async () => { 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.updateProfile({nickname:'test'}); 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(result.duplicate,true); 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/); }); 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('wxfile://tmp/photo.jpg'), 'wxfile://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); }); diff --git a/weapp/tests/navigation.test.cjs b/weapp/tests/navigation.test.cjs index 99cd8c3..ce7d6ea 100644 --- a/weapp/tests/navigation.test.cjs +++ b/weapp/tests/navigation.test.cjs @@ -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',()=>{ 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')); 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')); diff --git a/weapp/tests/payment.test.cjs b/weapp/tests/payment.test.cjs new file mode 100644 index 0000000..f75b144 --- /dev/null +++ b/weapp/tests/payment.test.cjs @@ -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); + } +}); diff --git a/weapp/tests/permissions.test.cjs b/weapp/tests/permissions.test.cjs index 6534d8d..373e94e 100644 --- a/weapp/tests/permissions.test.cjs +++ b/weapp/tests/permissions.test.cjs @@ -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.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'); - 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()=>{ diff --git a/weapp/tests/request.test.cjs b/weapp/tests/request.test.cjs index a9f71ee..ed97c88 100644 --- a/weapp/tests/request.test.cjs +++ b/weapp/tests/request.test.cjs @@ -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}); assert.equal(response.page_size,20); 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[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); }); test('multipart upload retains action and token when Android omits headers',async()=>{ 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"}'});} }); - await uploadFileCompat({url:'https://past.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'); + await uploadFileCompat({url:'https://we.sundynix.cn/api/uploadPartnerAvatar',filePath:'test.jpg',name:'file',formData:{partner_id:87}}); + assert.equal(upload.url,'https://we.sundynix.cn/api/uploadPartnerAvatar?token=test-token'); assert.equal(upload.formData.action,'uploadPartnerAvatar'); assert.equal(upload.formData.token,'test-token'); assert.equal(upload.formData.partnerId,87); diff --git a/weapp/tests/smoking.test.cjs b/weapp/tests/smoking.test.cjs index 04da075..bad218f 100644 --- a/weapp/tests/smoking.test.cjs +++ b/weapp/tests/smoking.test.cjs @@ -5,7 +5,7 @@ const createLoader=require('./load-module.cjs'); function setup(action,info) { 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 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')); 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}); diff --git a/weapp/tests/storage-tiers.test.cjs b/weapp/tests/storage-tiers.test.cjs new file mode 100644 index 0000000..ebd76a7 --- /dev/null +++ b/weapp/tests/storage-tiers.test.cjs @@ -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'); +}); diff --git a/weapp/utils/request.js b/weapp/utils/request.js index 86a2267..fc29de1 100644 --- a/weapp/utils/request.js +++ b/weapp/utils/request.js @@ -73,7 +73,7 @@ function maybeRedirectStorage(data) { if (!isStorageQuotaError(data)) return false; wx.showModal({ title: '存储空间不足', - content: '免费空间已用完,升级为无限空间会员后即可继续上传。', + content: '存储空间已用完,请选择更大容量的套餐后继续上传。', confirmText: '去升级', cancelText: '暂不', success: function (res) { @@ -402,6 +402,7 @@ const api = { // 存储空间 storageInfo: () => request('storageInfo', null, 'GET'), storageCreateOrder: (data) => request('storageCreateOrder', data), + storageOrderStatus: (number) => request('storageOrderStatus', { out_trade_no: number }, 'GET'), // 操作记录 activityLogList: (data) => request('activityLogList', data, 'GET'), diff --git a/weapp/utils/storage-guard.js b/weapp/utils/storage-guard.js index e5947b8..3265921 100644 --- a/weapp/utils/storage-guard.js +++ b/weapp/utils/storage-guard.js @@ -2,12 +2,12 @@ // 存储空间统一拦截:上传照片/图片前检查剩余空间,超出时在按钮处直接阻止上传。 const { api } = require('./request'); -// 判断是否超出配额(会员无限空间,不拦截) +// 只有无限容量套餐免配额检查,付费有限套餐同样需要校验。 function quotaExceeded(d, extraBytes) { if (!d) return false; - const isVip = d.is_vip || d.isVip; - if (isVip) return false; - const free = d.free_bytes || d.freeBytes || 0; + const unlimited = d.is_unlimited || d.isUnlimited; + if (unlimited) return false; + 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; return used + (extraBytes || 0) > free; } @@ -16,7 +16,7 @@ function quotaExceeded(d, extraBytes) { function showQuotaBlocked() { wx.showModal({ title: '存储空间不足', - content: '当前存储空间已满,请升级为无限空间会员后再上传照片。', + content: '当前存储空间已满,请选择更大容量的套餐后再上传照片。', confirmText: '去升级', cancelText: '取消', success: function (res) { diff --git a/部署验收清单.md b/部署验收清单.md deleted file mode 100644 index 827d4a3..0000000 --- a/部署验收清单.md +++ /dev/null @@ -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,再提交审核发布。