2 Commits

Author SHA1 Message Date
wangjia e0c7baa7cb fix(sec): 安全评审加固——goroutine panic 恢复 + 下单 body 上限 + 错误脱敏
- 后台 goroutine (notifyBiz/SyncPending) 加 defer recover,防 panic 拖垮进程
- 下单请求体 64KB 上限 (MaxBytesReader),防超大 body 内存 DoS
- 下单错误不再回传原始 err.Error(),内部详情只进日志
- (另: pay nginx 加 per-IP 限流 limit_req,服务器侧配置)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn
2026-07-03 23:53:11 +08:00
wangjia ef0fa39ce4 chore: 加一键发布脚本 deploy.sh(编译→传ali→备份→重启→探活,失败自动回滚)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn
2026-07-03 22:49:52 +08:00
4 changed files with 62 additions and 6 deletions
+7 -1
View File
@@ -60,8 +60,14 @@ pay/
## 构建部署
**一键发布到 ali(推荐)**
```bash
./deploy.sh # 交叉编译 linux/amd64 → 传 ali → 备份 → 重启 pay → 探活(失败自动回滚)
```
手动构建:
```bash
go build -o payd . # 纯 Go,无需 gcc,可直接拷到阿里云 Linux 运行
```
生产改 `config.yaml``server.mode: release`、`base_url` 为正式 HTTPS 域名、商户 `production: true` 并换正式密钥
生产改 `config.yaml``server.mode: release`、`base_url` 为正式 HTTPS 域名、商户 `production: true`;密钥走环境变量(`/etc/pay/pay.env`Bitwarden 灌),不入库
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# pay 一键发布:交叉编译 → 传 ali → 备份 → 重启 → 探活(失败自动回滚)。
# 用法:在 pay 仓根目录执行 ./deploy.sh
# 前提:~/.ssh/config 有 ali 别名;ali 上 systemd 服务名 pay、二进制 /opt/pay/payd。
# 密钥不在此脚本处理——运行时由 /etc/pay/pay.envBitwarden 灌)提供。
set -euo pipefail
HOST=ali
DIR=/opt/pay
BIN=/tmp/payd-release
echo "[1/4] 交叉编译 linux/amd64 …"
GOOS=linux GOARCH=amd64 go build -o "$BIN" .
echo "[2/4] 上传到 $HOST:$DIR/payd-new …"
scp -q "$BIN" "$HOST:$DIR/payd-new"
echo "[3/4] 备份旧版 + 切换 + 重启 …"
ssh "$HOST" "cp $DIR/payd $DIR/payd.bak && mv $DIR/payd-new $DIR/payd && chmod +x $DIR/payd && systemctl restart pay"
echo "[4/4] 探活 …"
if ssh "$HOST" "curl -fsS --retry 8 --retry-delay 1 --retry-connrefused -o /dev/null -w 'health %{http_code}\n' http://127.0.0.1:8080/health"; then
echo "✅ 发布完成(上一版已备份在 $DIR/payd.bak"
else
echo "❌ 探活失败,自动回滚到上一版 …"
ssh "$HOST" "cp $DIR/payd.bak $DIR/payd && systemctl restart pay"
echo "↩️ 已回滚。请检查本次改动后重试。"
exit 1
fi
+12 -5
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
@@ -17,6 +18,9 @@ import (
"github.com/wangjia/pay/internal/util"
)
// maxOrderBodyBytes 下单请求体上限(防超大 body 内存 DoS)。
const maxOrderBodyBytes = 64 << 10 // 64KB
// isMobileUA 粗略判断是否手机浏览器(决定支付宝走 wap.pay 拉起 App 还是 page.pay 扫码)。
func isMobileUA(ua string) bool {
ua = strings.ToLower(ua)
@@ -75,14 +79,14 @@ func verifyBizSign(c *gin.Context, system string, rawBody []byte) error {
// 独立收款(浏览器 /paytest):只传 product_id,无需签名。
// 业务对接(jiu 后端):带 biz_system/biz_ref + HMAC 签名头,服务端校验后受理。
func (h *OrderHandler) Create(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "读取请求失败")
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败")
return
}
var req createOrderRequest
if err := json.Unmarshal(raw, &req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误"+err.Error())
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
return
}
if req.ProductID == 0 {
@@ -103,7 +107,9 @@ func (h *OrderHandler) Create(c *gin.Context) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
return
}
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
// 内部错误详情只进日志,不回给客户端(防信息泄露)。
log.Printf("[order] 下单失败 product_id=%d biz=%q: %v", req.ProductID, req.BizSystem, err)
util.RespondError(c, http.StatusInternalServerError, "create_failed", "下单失败,请稍后重试")
return
}
util.RespondSuccess(c, gin.H{
@@ -127,7 +133,8 @@ func (h *OrderHandler) CreateQR(c *gin.Context) {
util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error())
return
}
util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error())
log.Printf("[order] 扫码下单失败 product_id=%d: %v", req.ProductID, err)
util.RespondError(c, http.StatusInternalServerError, "create_failed", "下单失败,请稍后重试")
return
}
util.RespondSuccess(c, gin.H{
+14
View File
@@ -253,6 +253,13 @@ func (s *OrderService) GetByOutTradeNo(outTradeNo string) (*model.Order, error)
// SyncPending 兜底:把近期待支付订单拿去主动查单,命中已支付则补记(防回调丢失)。
func (s *OrderService) SyncPending(ctx context.Context, maxAge time.Duration) {
// 后台循环调用;恢复 panic 防止拖垮整个进程。
defer func() {
if r := recover(); r != nil {
log.Printf("[query_sync] 发生 panic 已恢复: %v", r)
}
}()
var orders []model.Order
cutoff := time.Now().Add(-maxAge)
if err := s.db.Where("status = ? AND created_at > ?", model.OrderPending, cutoff).
@@ -295,6 +302,13 @@ func (s *OrderService) StartQuerySync(interval, maxAge time.Duration) {
// notifyBizByOutTradeNo 向业务系统推送「支付成功」webhook(签名)。成功则置 BizNotified,失败留待重试。
func (s *OrderService) notifyBizByOutTradeNo(outTradeNo string) {
// 该函数在 goroutine 中运行(applyPaid 异步触发 + 重试循环);Go 中未恢复的 goroutine panic 会整进程崩溃。
defer func() {
if r := recover(); r != nil {
log.Printf("[biz_notify] 订单 %s 回调发生 panic 已恢复: %v", outTradeNo, r)
}
}()
var o model.Order
if err := s.db.First(&o, "out_trade_no = ?", outTradeNo).Error; err != nil {
return