diff --git a/backend/controllers/plugin_wechat.go b/backend/controllers/plugin_wechat.go
index 76ffde6..6db0091 100644
--- a/backend/controllers/plugin_wechat.go
+++ b/backend/controllers/plugin_wechat.go
@@ -8,6 +8,7 @@ import (
"net/http"
"git.pcux.cn/xuans/Smail/config"
+ "git.pcux.cn/xuans/Smail/controllers/wechatcallback"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/hooks/framework"
@@ -157,17 +158,12 @@ func WeChatBinding(ctx *appcontext.Context, w http.ResponseWriter, req *http.Req
// WeChatBindingCallback 不依赖手机浏览器的登录 Cookie,也不自动完成绑定。
func WeChatBindingCallback(w http.ResponseWriter, req *http.Request) {
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Referrer-Policy", "no-referrer")
- w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
service := hooks.PluginConfigService()
if !config.IsSetupReady() || service == nil || service.ClaimWeChatBinding(req.Context(), req.URL.Query().Get("state"), req.URL.Query().Get("code")) != nil {
- http.Error(w, "授权失败或二维码已失效,请返回 Smail 页面重新扫码。", http.StatusBadRequest)
+ wechatcallback.Write(w, false)
return
}
- _, _ = w.Write([]byte("微信授权已完成。请返回发起扫码的 Smail 页面,核对微信昵称后点击确认绑定。"))
+ wechatcallback.Write(w, true)
}
func TestWeChatMessage(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
diff --git a/backend/controllers/wechatcallback/page.css b/backend/controllers/wechatcallback/page.css
new file mode 100644
index 0000000..08d0015
--- /dev/null
+++ b/backend/controllers/wechatcallback/page.css
@@ -0,0 +1,148 @@
+:root {
+ color-scheme: light;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+ color: #182638;
+ background: #f3f6fa;
+ -webkit-text-size-adjust: 100%;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ min-height: 100svh;
+ padding: 48px 20px 28px;
+ padding-top: max(48px, env(safe-area-inset-top));
+ padding-bottom: max(28px, env(safe-area-inset-bottom));
+}
+
+.result-card {
+ width: 100%;
+ max-width: 440px;
+ margin: 0 auto;
+ padding: 28px 24px 24px;
+ border: 1px solid #e5eaf1;
+ border-radius: 24px;
+ background: #fff;
+ box-shadow: 0 12px 40px rgb(32 56 85 / 5%);
+ text-align: center;
+}
+
+.brand {
+ color: #254c8a;
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: 0.3px;
+}
+
+.brand span {
+ margin-left: 8px;
+ color: #697789;
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.status-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 72px;
+ height: 72px;
+ margin: 32px auto 16px;
+ border-radius: 50%;
+ font-size: 38px;
+ font-weight: 600;
+ line-height: 1;
+}
+
+.success {
+ color: #158057;
+ background: #e9f7f0;
+}
+
+.warning {
+ color: #a15b12;
+ background: #fff4e3;
+}
+
+.status-label {
+ margin: 0 0 8px;
+ color: #637186;
+ font-size: 13px;
+}
+
+h1 {
+ margin: 0;
+ font-size: 26px;
+ line-height: 1.4;
+ font-weight: 650;
+}
+
+.description {
+ margin: 12px 0 24px;
+ color: #526174;
+ font-size: 15px;
+ line-height: 1.7;
+}
+
+.steps {
+ display: grid;
+ gap: 16px;
+ margin: 0;
+ padding: 20px 16px;
+ border-radius: 16px;
+ background: #f6f8fb;
+ list-style: none;
+ text-align: left;
+}
+
+.steps li {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ font-size: 15px;
+ line-height: 24px;
+}
+
+.step-number {
+ flex: 0 0 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: #e8eef8;
+ color: #355987;
+ font-size: 13px;
+ font-weight: 600;
+ text-align: center;
+}
+
+.notice {
+ margin: 20px 0;
+ color: #647286;
+ font-size: 13px;
+ line-height: 1.8;
+}
+
+footer {
+ padding-top: 18px;
+ border-top: 1px solid #edf0f5;
+ color: #68768a;
+ font-size: 12px;
+ line-height: 1.7;
+}
+
+@media (max-width: 360px) {
+ body {
+ padding: 24px 12px;
+ }
+
+ .result-card {
+ padding: 24px 16px;
+ }
+
+ .steps {
+ padding: 16px 12px;
+ }
+}
diff --git a/backend/controllers/wechatcallback/page.go b/backend/controllers/wechatcallback/page.go
new file mode 100644
index 0000000..91c4f28
--- /dev/null
+++ b/backend/controllers/wechatcallback/page.go
@@ -0,0 +1,46 @@
+package wechatcallback
+
+import (
+ "bytes"
+ "crypto/sha256"
+ _ "embed"
+ "encoding/base64"
+ "html/template"
+ "net/http"
+)
+
+// 页面随主程序发布,无需外部字体、脚本或手机浏览器登录状态。
+//
+//go:embed page.html
+var pageHTML string
+
+//go:embed page.css
+var pageCSS string
+
+var resultTemplate = template.Must(template.New("wechat-result").Parse(pageHTML))
+
+// Write 只呈现授权结果,不接收身份凭据,也不执行账号绑定。
+func Write(w http.ResponseWriter, authorized bool) {
+ digest := sha256.Sum256([]byte(pageCSS))
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
+ var body bytes.Buffer
+ data := struct {
+ Authorized bool
+ CSS template.CSS
+ }{authorized, template.CSS(pageCSS)} // 仅信任编译时内嵌样式,禁止传入请求内容。
+ if err := resultTemplate.Execute(&body, data); err != nil {
+ http.Error(w, "页面暂时无法显示,请返回 Smail 查看绑定状态。", http.StatusInternalServerError)
+ return
+ }
+ status := http.StatusOK
+ if !authorized {
+ status = http.StatusBadRequest
+ }
+ w.WriteHeader(status)
+ _, _ = w.Write(body.Bytes())
+}
diff --git a/backend/controllers/wechatcallback/page.html b/backend/controllers/wechatcallback/page.html
new file mode 100644
index 0000000..4eccdfd
--- /dev/null
+++ b/backend/controllers/wechatcallback/page.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ {{if .Authorized}}微信授权成功{{else}}微信授权未完成{{end}} · Smail
+
+
+
+
+ Smail 微信通知
+ {{if .Authorized}}
+ ✓
+ 等待确认绑定
+ 微信授权成功
+ 还差一步,请返回 Smail 确认绑定。
+
+ - 1回到发起扫码的 Smail 页面
+ - 2核对显示的微信昵称
+ - 3点击「确认绑定」
+
+ 本次授权不会自动绑定账号。
请在二维码有效期内完成确认。
+ {{else}}
+ !
+ 请重新扫码
+ 微信授权未完成
+ 二维码可能已失效,或本次授权未完成。
+
+ - 1回到发起扫码的 Smail 页面
+ - 2关闭绑定弹窗,重新生成二维码
+ - 3使用本人微信重新扫码授权
+
+ 若已在 Smail 确认绑定,以原页面状态为准。
请勿刷新或重复打开旧授权链接。
+ {{end}}
+
+
+
+
diff --git a/backend/controllers/wechatcallback/page_test.go b/backend/controllers/wechatcallback/page_test.go
new file mode 100644
index 0000000..7100703
--- /dev/null
+++ b/backend/controllers/wechatcallback/page_test.go
@@ -0,0 +1,72 @@
+package wechatcallback
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestWriteAuthorizationResult(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ authorized bool
+ status int
+ message string
+ }{
+ {"authorized", true, http.StatusOK, "等待确认绑定"},
+ {"expired", false, http.StatusBadRequest, "请重新扫码"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ response := httptest.NewRecorder()
+ Write(response, test.authorized)
+ if response.Code != test.status {
+ t.Fatalf("授权结果状态码错误:%d", response.Code)
+ }
+ body := response.Body.String()
+ for _, expected := range []string{test.message, `lang="zh-CN"`, `width=device-width, initial-scale=1`, "回到发起扫码的 Smail 页面", ""} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("结果页缺少预期内容:%s", expected)
+ }
+ }
+ for _, forbidden := range []string{"