Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9f89f6526 | |||
| e0c7baa7cb | |||
| ef0fa39ce4 |
@@ -0,0 +1,35 @@
|
||||
name: Checks
|
||||
|
||||
# PR 与合并 main 时跑 Go 静态闸(vet + build + test),发版前先绿。
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: checks-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
go:
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: go vet
|
||||
run: |
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
go vet ./...
|
||||
|
||||
- name: go build
|
||||
run: |
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
go build ./...
|
||||
|
||||
- name: go test
|
||||
run: |
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
go test ./...
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Deploy
|
||||
|
||||
# 打 v* tag 即自动发版到 ali(单 Go 二进制 payd,systemd 服务 pay,:8080/health)。
|
||||
# git tag v1.0.2 && git push origin v1.0.2
|
||||
# runs-on: mac —— 复用 jiu 的自建 mac runner(已注册、装了 go)。
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]*.[0-9]*.[0-9]*'
|
||||
|
||||
concurrency:
|
||||
group: deploy-pay
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-deploy:
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Test (go test)
|
||||
run: |
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
go test ./...
|
||||
|
||||
- name: Deploy → Ali(交叉编译 + 传 + 备份 + 重启 + 探活/回滚)
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.ALI_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.ALI_USER }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.ALI_SSH_KEY }}
|
||||
run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Notify
|
||||
if: always()
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
|
||||
@@ -60,8 +60,21 @@ pay/
|
||||
|
||||
## 构建部署
|
||||
|
||||
**打 tag 自动发版到 ali(推荐)**:Gitea Actions 在 `v*` tag 上跑 `go test` → 交叉编译 → 传 ali → 备份 → 重启 → 探活(失败自动回滚)。
|
||||
```bash
|
||||
git tag v1.0.2 && git push origin v1.0.2 # 触发 .gitea/workflows/deploy.yml
|
||||
```
|
||||
- runs-on `mac`(复用 jiu 的自建 runner);PR/合并 main 另跑 `checks.yml`(vet+build+test)。
|
||||
- **首次需在 pay 仓 Gitea → Settings → Actions → Secrets 配**:`ALI_SSH_KEY`(授权 ali root 的部署私钥整段)、`ALI_HOST`、`ALI_USER`(root);可选 `TELEGRAM_TOKEN`/`TELEGRAM_CHAT_ID`(发布通知,不配则跳过)。密钥只进 Gitea Secrets,不入库。
|
||||
|
||||
**本地一键发布(等价手动路径)**:
|
||||
```bash
|
||||
./deploy.sh # 交叉编译 linux/amd64 → 传 ali(ssh 别名)→ 备份 → 重启 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 灌),不入库。
|
||||
|
||||
@@ -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.env(Bitwarden 灌)提供。
|
||||
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
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# _env.sh — 共享 CI 镜像/PATH 环境,供其他脚本 `source`。幂等:只在未设置时赋值。
|
||||
|
||||
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||
|
||||
# 自建 mac runner 上 go 装在 Homebrew;确保在 PATH(对其他环境无害)。
|
||||
case ":${PATH}:" in
|
||||
*":/opt/homebrew/bin:"*) ;;
|
||||
*) export PATH="/opt/homebrew/bin:${PATH}" ;;
|
||||
esac
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ci/deploy.sh <tag> — CI 发布:交叉编译 linux/amd64 → 传 ali → 备份 →
|
||||
# 切换 → 重启 pay → 探活(失败自动回滚)。本地 ./deploy.sh 的 CI 版:不用
|
||||
# ~/.ssh 里的 `ali` 别名,而是从 secret 注入部署私钥、从 secret 取主机。
|
||||
#
|
||||
# 由 .gitea/workflows/deploy.yml 在打 v* tag 时触发(runs-on: mac,复用 jiu
|
||||
# 的自建 mac runner)。业务密钥不在此处理——运行时由 ali 上 /etc/pay/pay.env
|
||||
# 提供(Bitwarden 灌)。
|
||||
#
|
||||
# 需要的 secrets(pay 仓 Gitea → Settings → Actions → Secrets):
|
||||
# ALI_SSH_KEY 已授权 ali root 的部署私钥(整段,OpenSSH 或 PEM)
|
||||
# ALI_HOST ali 可达地址
|
||||
# ALI_USER 部署用户(默认 root)
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=scripts/ci/_env.sh
|
||||
. "$(dirname "$0")/_env.sh"
|
||||
|
||||
TAG="${1:-manual}"
|
||||
TARGET_HOST="${DEPLOY_HOST:?ALI_HOST secret 未设}"
|
||||
TARGET_USER="${DEPLOY_USER:-root}"
|
||||
DIR=/opt/pay
|
||||
BIN=dist/payd
|
||||
KEY="${HOME}/.ssh/pay_deploy.key"
|
||||
|
||||
echo "==> deploy: tag=${TAG} host=${TARGET_HOST}"
|
||||
|
||||
echo "[1/4] 交叉编译 linux/amd64 …"
|
||||
mkdir -p dist
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o "$BIN" .
|
||||
|
||||
echo "[2/4] 写部署 key + 上传到 ${TARGET_HOST}:${DIR}/payd-new …"
|
||||
mkdir -p "${HOME}/.ssh"
|
||||
# printf '%s\n' 末尾补换行:Gitea 存 secret 会去掉结尾换行,而 OpenSSH 格式
|
||||
# 私钥缺结尾换行会被判 "invalid format" 拒绝加载 → 退化成无密钥 Permission denied。
|
||||
printf '%s\n' "${DEPLOY_SSH_KEY:?ALI_SSH_KEY secret 未设}" > "$KEY"
|
||||
chmod 600 "$KEY"
|
||||
ssh-keyscan -H "$TARGET_HOST" >> "${HOME}/.ssh/known_hosts" 2>/dev/null || true
|
||||
SSH="ssh -i $KEY -o StrictHostKeyChecking=no"
|
||||
SCP="scp -O -i $KEY -o StrictHostKeyChecking=no"
|
||||
|
||||
cleanup() { rm -f "$KEY"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
$SCP "$BIN" "${TARGET_USER}@${TARGET_HOST}:${DIR}/payd-new"
|
||||
|
||||
echo "[3/4] 备份旧版 + 切换 + 重启 …"
|
||||
$SSH "${TARGET_USER}@${TARGET_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 "${TARGET_USER}@${TARGET_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 "${TARGET_USER}@${TARGET_HOST}" "cp ${DIR}/payd.bak ${DIR}/payd && systemctl restart pay"
|
||||
echo "↩️ 已回滚。请检查本次改动后重试。"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# notify.sh <tag> <status> — 发 Telegram 发布通知。secrets 未配则静默跳过
|
||||
# (所以没设 TELEGRAM_* 也不会让流水线失败)。
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:-}"
|
||||
STATUS="${2:-}"
|
||||
|
||||
if [ -z "${TELEGRAM_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
|
||||
echo "==> notify: 未配 TELEGRAM_* secrets,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
ICON="✅"; LABEL="发布成功"
|
||||
else
|
||||
ICON="❌"; LABEL="发布失败"
|
||||
fi
|
||||
|
||||
MSG="${ICON} pay ${TAG} ${LABEL}
|
||||
https://pay.51yanmei.com"
|
||||
|
||||
curl -f -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
--data-urlencode "text=${MSG}" \
|
||||
> /dev/null || echo "==> notify: 发送失败(忽略,不阻断流水线)"
|
||||
|
||||
echo "==> notify: done (${STATUS})"
|
||||
Reference in New Issue
Block a user