From 7c75edcc6174f4f1e55dcb5c96dce17f2203bb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Mon, 28 Sep 2026 22:17:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=94=AF=E4=BB=98=E4=B8=8D?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/views/package/list.vue | 10 ++- backend/src/views/product/index.vue | 10 ++- ...30900.pem => cert_1789457351304095588.pem} | 0 ...945300.pem => key_1789457354418136548.pem} | 0 go/conf/app.conf | 10 ++- go/controllers/platform_payment.go | 4 +- go/services/payment/cert_path.go | 81 +++++++++++++++++++ go/services/payment/channel.go | 4 + 8 files changed, 113 insertions(+), 6 deletions(-) rename go/certs/payment/wechat/{cert_1789456993453130900.pem => cert_1789457351304095588.pem} (100%) rename go/certs/payment/wechat/{key_1789456997350945300.pem => key_1789457354418136548.pem} (100%) create mode 100644 go/services/payment/cert_path.go diff --git a/backend/src/views/package/list.vue b/backend/src/views/package/list.vue index 7999078..00ad4b9 100644 --- a/backend/src/views/package/list.vue +++ b/backend/src/views/package/list.vue @@ -301,8 +301,14 @@ const checkOrder = async (orderNo: string, silent = false) => { ElMessage.success("购买成功,套餐已开通"); payVisible.value = false; loadMyOrders(); - // 重建动态路由,把新开通模块的「功能未开通」菜单占位页切换为真实页面 - reloadMenusAndDynamicRoutes().catch(() => {}); + // 重建动态路由,把新开通模块的「功能未开通」菜单占位页切换为真实页面。 + // 必须等重建完成后再跳首页,否则会按旧的菜单路由跳转、落到占位页 + try { + await reloadMenusAndDynamicRoutes(); + } catch { + /* 忽略:进入首页时路由守卫会再拉一次菜单 */ + } + router.replace("/home"); return; } if (status === "paid") { diff --git a/backend/src/views/product/index.vue b/backend/src/views/product/index.vue index 2ab0da6..477b47c 100644 --- a/backend/src/views/product/index.vue +++ b/backend/src/views/product/index.vue @@ -312,8 +312,14 @@ const checkOrder = async (silent = false) => { ElMessage.success("购买成功,功能已开通"); payVisible.value = false; loadMyOrders(); - // 重建动态路由,把刚开通模块的「功能未开通」菜单占位页切换为真实页面 - reloadMenusAndDynamicRoutes().catch(() => {}); + // 重建动态路由,把刚开通模块的「功能未开通」菜单占位页切换为真实页面。 + // 必须等重建完成后再跳首页,否则会按旧的菜单路由跳转、落到占位页 + try { + await reloadMenusAndDynamicRoutes(); + } catch { + /* 忽略:进入首页时路由守卫会再拉一次菜单 */ + } + router.replace("/home"); return; } if (status === "paid") { diff --git a/go/certs/payment/wechat/cert_1789456993453130900.pem b/go/certs/payment/wechat/cert_1789457351304095588.pem similarity index 100% rename from go/certs/payment/wechat/cert_1789456993453130900.pem rename to go/certs/payment/wechat/cert_1789457351304095588.pem diff --git a/go/certs/payment/wechat/key_1789456997350945300.pem b/go/certs/payment/wechat/key_1789457354418136548.pem similarity index 100% rename from go/certs/payment/wechat/key_1789456997350945300.pem rename to go/certs/payment/wechat/key_1789457354418136548.pem diff --git a/go/conf/app.conf b/go/conf/app.conf index 2fc64e9..fc4c867 100644 --- a/go/conf/app.conf +++ b/go/conf/app.conf @@ -92,4 +92,12 @@ payment_secret_key = f656fe85ed6a1caba9f19966d8cdde46114709969bea5875cde7664d4ad # TODO: 填你自己服务器的公网 HTTPS 域名(如 https://api.yunzer.com.cn),不是申请来的。 # 要求:公网可访问、HTTPS、无鉴权无重定向;微信 JSAPI 还需在商户平台「支付授权目录」登记同一域名。 # 本地开发收不到渠道回调,留空即可(回调地址退化为相对路径 /api/payment/callback/{渠道},由网关补齐域名)。 -payment_callback_base = https://api.yunzer.cn \ No newline at end of file +payment_callback_base = https://api.yunzer.cn + +# 渠道证书落盘根目录(各渠道目录的父目录,实际路径 = {payment_cert_dir}/{渠道}/xxx.pem)。 +# 留空时按进程工作目录下的 certs/payment 处理。 +# 历史数据里 cert_json 存的是相对路径(相对「当初上传时」的进程工作目录), +# 若服务的工作目录与当初不一致(例如上传时是 /www/wwwroot/api.yunzer.cn,现在跑在 /app), +# 把这里配成当初上传用的绝对目录即可继续读到旧证书,无需重新上传: + payment_cert_dir = /www/wwwroot/api.yunzer.cn/certs/payment/wechat +# payment_cert_dir = \ No newline at end of file diff --git a/go/controllers/platform_payment.go b/go/controllers/platform_payment.go index e7d1cf0..0f770c7 100644 --- a/go/controllers/platform_payment.go +++ b/go/controllers/platform_payment.go @@ -384,7 +384,9 @@ func (c *PlatformPaymentController) UploadCertificate() { } defer func() { _ = file.Close() }() - dir := filepath.Join("certs", "payment", channel) + // 落盘目录:payment_cert_dir 配置优先,未配置时用相对工作目录的 certs/payment/<渠道>。 + // 入库保持相对路径——本地开发与线上可能共用同一个库,存绝对路径会互相覆盖对方环境。 + dir := payment.CertDir(channel) if err := os.MkdirAll(dir, 0o700); err != nil { c.jsonErr(500, 500, "创建证书目录失败: "+err.Error()) return diff --git a/go/services/payment/cert_path.go b/go/services/payment/cert_path.go new file mode 100644 index 0000000..0cf590f --- /dev/null +++ b/go/services/payment/cert_path.go @@ -0,0 +1,81 @@ +package payment + +import ( + "os" + "path/filepath" + "strings" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 渠道证书落盘路径解析 +// +// 历史实现把证书存成相对路径(certs/payment/<渠道>/xxx.pem), +// 该相对路径在「上传时」与「读取时」分别相对各自的进程工作目录解析。 +// 一旦服务的工作目录发生变化(例如从 /www/wwwroot/api.yunzer.cn 换到 /app), +// 库里记的路径就指向不存在的文件,报 “The system cannot find the file specified”。 +// +// 因此读取时统一做一次解析: +// - 落盘:写入 payment_cert_dir(未配置时退回工作目录下的 certs/payment),入库仍存相对路径 +// (本地开发与线上可能共用同一个库,存绝对路径会互相覆盖对方环境); +// - 读取:先按当前工作目录解析,再按配置的 payment_cert_dir 根目录兜底解析。 +// ============================================================= + +// legacyCertPrefix 历史相对路径前缀,用于把 certs/payment/<渠道>/xxx.pem 剥成 <渠道>/xxx.pem +const legacyCertPrefix = "certs/payment/" + +// CertBaseDir 渠道证书落盘根目录(各渠道目录的父目录) +func CertBaseDir() string { + dir, _ := beego.AppConfig.String("payment_cert_dir") + dir = strings.TrimSpace(dir) + if dir == "" { + dir = filepath.Join("certs", "payment") + } + return dir +} + +// CertDir 某渠道的证书目录 +func CertDir(channel string) string { + return filepath.Join(CertBaseDir(), filepath.Base(channel)) +} + +// ResolveCertPath 把库中可能存的相对路径解析为当前可读的路径。 +// 依次尝试:绝对路径 -> 相对当前工作目录 -> 相对 payment_cert_dir 根目录(兼容历史相对路径)。 +// 都读不到时原样返回,让上层的 os.ReadFile 报出原始路径便于排查。 +func ResolveCertPath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if filepath.IsAbs(p) { + return p + } + // 相对当前工作目录(历史行为,能读到就不动) + if certPathExists(p) { + return p + } + // 兼容历史相对路径:剥掉 certs/payment 前缀后挂到配置的根目录下 + if cand := filepath.Join(CertBaseDir(), certRelative(p)); certPathExists(cand) { + return cand + } + // 兜底:按文件名在根目录下找 + if cand := filepath.Join(CertBaseDir(), filepath.Base(p)); certPathExists(cand) { + return cand + } + return p +} + +// certRelative 从历史相对路径中剥掉 certs/payment 前缀 +func certRelative(p string) string { + norm := filepath.ToSlash(filepath.Clean(p)) + if i := strings.Index(norm, legacyCertPrefix); i >= 0 { + norm = norm[i+len(legacyCertPrefix):] + } + return filepath.FromSlash(norm) +} + +func certPathExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/go/services/payment/channel.go b/go/services/payment/channel.go index 048f619..0c29331 100644 --- a/go/services/payment/channel.go +++ b/go/services/payment/channel.go @@ -221,6 +221,10 @@ func LoadChannelConfig(channel string) (*ChannelConfig, error) { } if row.CertJSON != nil && *row.CertJSON != "" { _ = json.Unmarshal([]byte(*row.CertJSON), &cfg.CertPaths) + // 历史数据存的是相对路径,这里统一解析为当前可读路径,避免依赖进程工作目录 + for key, val := range cfg.CertPaths { + cfg.CertPaths[key] = ResolveCertPath(val) + } } return cfg, nil }