金泰访客查询
+本页由本地 Go 服务生成,只监听 127.0.0.1。服务端代发金泰请求,浏览器不再直接跨域访问。
+ +{{.RawJSON}}{{end}}
+ 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(`
本页由本地 Go 服务生成,只监听 127.0.0.1。服务端代发金泰请求,浏览器不再直接跨域访问。
+ +{{.RawJSON}}{{end}}
+