From 9e431f2210cfe70220b9045fbdd256d991efb3a3 Mon Sep 17 00:00:00 2001 From: wangqiyang Date: Tue, 1 Sep 2026 22:40:57 +0800 Subject: [PATCH] Add Jintai visitor proxy server --- cmd/jintai-visitor-proxy/main.go | 675 +++++++++++++++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100644 cmd/jintai-visitor-proxy/main.go diff --git a/cmd/jintai-visitor-proxy/main.go b/cmd/jintai-visitor-proxy/main.go new file mode 100644 index 0000000..760938e --- /dev/null +++ b/cmd/jintai-visitor-proxy/main.go @@ -0,0 +1,675 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html" + "html/template" + "io" + "log" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +const ( + baseURL = "https://www.watvt.com" + listenAddr = "127.0.0.1:8787" + localSessionName = "jintai_local_sid" + + // 改成你自己的身份证号,也可以用环境变量 JINTAI_ID_CARD 覆盖。 + hardcodedIDCard = "请改成你的身份证号" +) + +var idCardRE = regexp.MustCompile(`^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9]|X)$`) + +type server struct { + mu sync.Mutex + sessions map[string]*jintaiClient +} + +type jintaiClient struct { + mu sync.Mutex + client *http.Client +} + +type apiResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` +} + +type pageData struct { + IDMasked string + VisitDate string + Captcha string + CaptchaImage template.URL + StatusClass string + StatusMessage string + ResultHTML template.HTML + RawJSON string +} + +func main() { + srv := &server{sessions: make(map[string]*jintaiClient)} + + mux := http.NewServeMux() + mux.HandleFunc("/", srv.handleHome) + + log.Printf("金泰访客查询本地服务已启动: http://%s", listenAddr) + log.Fatal(http.ListenAndServe(listenAddr, mux)) +} + +func (s *server) handleHome(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + idCard := configuredIDCard() + visitDate := strings.TrimSpace(r.URL.Query().Get("date")) + if visitDate == "" { + visitDate = todayText() + } + + data := pageData{ + IDMasked: maskIDCard(idCard), + VisitDate: visitDate, + Captcha: strings.TrimSpace(r.URL.Query().Get("captcha")), + StatusClass: "muted", + StatusMessage: "等待验证码。", + } + + if !idCardRE.MatchString(idCard) { + data.StatusClass = "err" + data.StatusMessage = "请先在 Go 文件里修改 hardcodedIDCard,或设置环境变量 JINTAI_ID_CARD。" + renderPage(w, data) + return + } + + client := s.clientFor(w, r) + if data.Captcha != "" { + raw, err := client.queryVisitor(r.Context(), idCard, visitDate, data.Captcha) + data.RawJSON = prettyJSON(raw) + if err != nil { + data.StatusClass = "err" + data.StatusMessage = "查询失败:" + err.Error() + } else { + var api apiResponse + if json.Unmarshal(raw, &api) == nil { + if api.Success { + data.StatusClass = "ok" + data.StatusMessage = "查询完成。" + } else { + data.StatusClass = "err" + data.StatusMessage = firstNonEmpty(api.Message, "查询接口返回失败。") + } + } else { + data.StatusClass = "ok" + data.StatusMessage = "查询完成,返回内容不是标准 JSON。" + } + data.ResultHTML = renderResultHTML(raw) + } + } + + if data.Captcha == "" || data.StatusClass == "err" { + img, err := client.fetchCaptcha(r.Context(), idCard) + if err != nil { + data.StatusClass = "err" + data.StatusMessage = "验证码获取失败:" + err.Error() + } else { + data.Captcha = "" + data.CaptchaImage = template.URL("data:image/png;base64," + img) + if data.ResultHTML == "" { + data.StatusClass = "ok" + data.StatusMessage = "验证码已加载,输入后点查询。" + } + } + } + + renderPage(w, data) +} + +func configuredIDCard() string { + if v := strings.TrimSpace(os.Getenv("JINTAI_ID_CARD")); v != "" { + return v + } + return strings.TrimSpace(hardcodedIDCard) +} + +func newJintaiClient() *jintaiClient { + jar, _ := cookiejar.New(nil) + return &jintaiClient{ + client: &http.Client{ + Jar: jar, + Timeout: 25 * time.Second, + }, + } +} + +func (s *server) clientFor(w http.ResponseWriter, r *http.Request) *jintaiClient { + s.mu.Lock() + defer s.mu.Unlock() + + if c, err := r.Cookie(localSessionName); err == nil && c.Value != "" { + if client := s.sessions[c.Value]; client != nil { + return client + } + } + + sid := randomID() + client := newJintaiClient() + s.sessions[sid] = client + + http.SetCookie(w, &http.Cookie{ + Name: localSessionName, + Value: sid, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + return client +} + +func randomID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} + +func (c *jintaiClient) fetchCaptcha(ctx context.Context, idCard string) (string, error) { + raw, err := c.postEncoded(ctx, "/boot/system/getCaptcha", map[string]string{ + "username": idCard, + }) + if err != nil { + return "", err + } + + var api apiResponse + if err := json.Unmarshal(raw, &api); err != nil { + return "", fmt.Errorf("验证码响应不是 JSON: %s", string(raw)) + } + if !api.Success { + return "", errors.New(firstNonEmpty(api.Message, string(raw))) + } + if api.Message == "" { + return "", errors.New("验证码图片为空") + } + + return api.Message, nil +} + +func (c *jintaiClient) queryVisitor(ctx context.Context, idCard, visitDate, captcha string) ([]byte, error) { + return c.postEncoded(ctx, "/boot/system/visitor/getVisitorHisByCard", map[string]string{ + "身份证号": idCard, + "随行人员": visitDate, + "备注": captcha, + }) +} + +func (c *jintaiClient) postEncoded(ctx context.Context, path string, payload any) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + + _ = c.warmup(ctx) + + enc, err := encodeStr(payload) + if err != nil { + return nil, err + } + + body, err := json.Marshal(map[string]string{"encodeStr": enc}) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("X-Access-Type", "app") + req.Header.Set("Origin", baseURL) + req.Header.Set("Referer", baseURL+"/app/#/user/visitorSearch") + req.Header.Set("User-Agent", "Mozilla/5.0") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + + return normalizeResponse(data) +} + +func (c *jintaiClient) warmup(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/app/", nil) + if err != nil { + return err + } + req.Header.Set("Referer", baseURL+"/app/#/user/visitorSearch") + req.Header.Set("User-Agent", "Mozilla/5.0") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return nil +} + +func encodeStr(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + escaped := url.QueryEscape(string(b)) + escaped = strings.ReplaceAll(escaped, "+", "%20") + return base64.StdEncoding.EncodeToString([]byte(escaped)), nil +} + +func decodeStr(s string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", err + } + return url.QueryUnescape(string(raw)) +} + +func normalizeResponse(data []byte) ([]byte, error) { + var wrapper struct { + ResponseEncodeStr string `json:"responseEncodeStr"` + } + if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.ResponseEncodeStr != "" { + decoded, err := decodeStr(wrapper.ResponseEncodeStr) + if err != nil { + return data, err + } + return []byte(decoded), nil + } + return data, nil +} + +func renderPage(w http.ResponseWriter, data pageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pageTemplate.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func renderResultHTML(raw []byte) template.HTML { + if len(bytes.TrimSpace(raw)) == 0 { + return "" + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return template.HTML(`
返回内容不是 JSON,已显示在原始响应里。
`) + } + + var b strings.Builder + b.WriteString(`
`) + b.WriteString(renderAny(value, "查询结果")) + b.WriteString(`
`) + return template.HTML(b.String()) +} + +func renderAny(v any, title string) string { + switch x := v.(type) { + case map[string]any: + if result, ok := x["result"]; ok { + var b strings.Builder + if msg, _ := x["message"].(string); msg != "" { + b.WriteString(`
` + html.EscapeString(msg) + `
`) + } + b.WriteString(renderAny(result, "访客记录")) + return b.String() + } + return renderObject(x, title) + case []any: + if len(x) == 0 { + return `
没有查到访客记录。
` + } + var b strings.Builder + for i, item := range x { + b.WriteString(renderAny(item, fmt.Sprintf("记录 %d", i+1))) + } + return b.String() + case string: + return `
` + html.EscapeString(x) + `
` + default: + return `
` + html.EscapeString(fmt.Sprint(x)) + `
` + } +} + +func renderObject(obj map[string]any, title string) string { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`

` + html.EscapeString(title) + `

`) + b.WriteString(`
`) + for _, k := range keys { + value := stringify(obj[k]) + b.WriteString(`
`) + b.WriteString(`
` + html.EscapeString(k) + `
`) + b.WriteString(`
`) + if src, ok := imageSrc(k, value); ok { + b.WriteString(`` + html.EscapeString(k) + ``) + b.WriteString(`
` + html.EscapeString(value) + `
`) + } else { + b.WriteString(html.EscapeString(value)) + } + b.WriteString(`
`) + } + b.WriteString(`
`) + return b.String() +} + +func stringify(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case float64, bool: + return fmt.Sprint(x) + default: + b, err := json.MarshalIndent(x, "", " ") + if err != nil { + return fmt.Sprint(x) + } + return string(b) + } +} + +func imageSrc(key, value string) (string, bool) { + v := strings.TrimSpace(value) + if v == "" { + return "", false + } + + lowerKey := strings.ToLower(key) + looksLikeImageField := strings.Contains(key, "二维码") || + strings.Contains(key, "图片") || + strings.Contains(lowerKey, "qr") || + strings.Contains(lowerKey, "image") + + if strings.HasPrefix(v, "data:image/") { + return v, true + } + if strings.HasPrefix(v, "/boot/") || strings.HasPrefix(v, "/app/") { + return baseURL + v, true + } + if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") { + u, err := url.Parse(v) + if err == nil { + p := strings.ToLower(u.Path) + if strings.HasSuffix(p, ".png") || strings.HasSuffix(p, ".jpg") || + strings.HasSuffix(p, ".jpeg") || strings.HasSuffix(p, ".gif") || + strings.HasSuffix(p, ".webp") || strings.HasSuffix(p, ".svg") || + looksLikeImageField { + return v, true + } + } + } + if looksLikeImageField && len(v) > 80 && regexp.MustCompile(`^[A-Za-z0-9+/=]+$`).MatchString(v) { + return "data:image/png;base64," + v, true + } + + return "", false +} + +func prettyJSON(raw []byte) string { + if len(bytes.TrimSpace(raw)) == 0 { + return "" + } + var out bytes.Buffer + if json.Indent(&out, raw, "", " ") == nil { + return out.String() + } + return string(raw) +} + +func todayText() string { + return time.Now().Format("2006-01-02") +} + +func maskIDCard(id string) string { + if !idCardRE.MatchString(id) || len(id) < 14 { + return "未配置" + } + return id[:6] + "********" + id[len(id)-4:] +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +var pageTemplate = template.Must(template.New("page").Parse(` + + + + + 金泰访客查询 + + + +
+

金泰访客查询

+

本页由本地 Go 服务生成,只监听 127.0.0.1。服务端代发金泰请求,浏览器不再直接跨域访问。

+ +
+
+
+ 身份证号 + {{.IDMasked}} +
+
+ +
+
+ + +
+ +
+ +
+ +
+ {{if .CaptchaImage}}验证码{{else}}验证码未加载{{end}} +
+
+
+ +
+
+ 刷新验证码 + +
+
+
+ +
+
{{.StatusMessage}}
+
+
+ + {{.ResultHTML}} + + {{if .RawJSON}}
{{.RawJSON}}
{{end}} +
+ +`))