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" qrcode "github.com/skip2/go-qrcode" ) 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,已显示在原始响应里。
`) } result := value message := "" if obj, ok := value.(map[string]any); ok { if msg, _ := obj["message"].(string); msg != "" { message = msg } if v, ok := obj["result"]; ok { result = v } } var b strings.Builder b.WriteString(`
`) if message != "" { b.WriteString(`
` + html.EscapeString(message) + `
`) } records := collectRecords(result) if len(records) > 0 { for i, record := range records { b.WriteString(renderVisitorCard(record, i+1, len(records))) } b.WriteString(`
展开表格`) b.WriteString(renderRecordsTable(records)) b.WriteString(`
`) } else { b.WriteString(renderAny(result, "访客记录")) } b.WriteString(`
`) return template.HTML(b.String()) } func collectRecords(v any) []map[string]any { switch x := v.(type) { case []any: records := make([]map[string]any, 0, len(x)) for _, item := range x { if record, ok := item.(map[string]any); ok { records = append(records, record) } } return records case map[string]any: if len(x) == 0 { return nil } return []map[string]any{x} case string: var nested any if json.Unmarshal([]byte(x), &nested) == nil { return collectRecords(nested) } } return nil } func renderVisitorCard(obj map[string]any, index, total int) string { start := firstField(obj, "开始时间", "来访时间") end := firstField(obj, "结束时间", "离开时间") qrText := firstField(obj, "备注", "二维码", "通行码", "访客码", "QRCode", "qrCode", "qr") qrSrc, qrErr := qrImageDataURL(qrText) var b strings.Builder b.WriteString(`
`) b.WriteString(`
访客二维码`) if total > 1 { b.WriteString(`` + html.EscapeString(fmt.Sprintf("%d / %d", index, total)) + ``) } b.WriteString(`
`) if qrSrc != "" { b.WriteString(`
访客二维码
`) } else { msg := "记录里没有可生成二维码的备注字段。" if qrErr != nil { msg = qrErr.Error() } b.WriteString(`
` + html.EscapeString(msg) + `
`) } b.WriteString(`
`) b.WriteString(renderMetaRow("开始时间", start)) b.WriteString(renderMetaRow("结束时间", end)) b.WriteString(`
`) return b.String() } func renderMetaRow(label, value string) string { if strings.TrimSpace(value) == "" { return "" } return `
` + html.EscapeString(label) + `` + html.EscapeString(value) + `
` } func renderRecordsTable(records []map[string]any) string { var b strings.Builder for i, record := range records { keys := make([]string, 0, len(record)) for k := range record { keys = append(keys, k) } sort.Strings(keys) if len(records) > 1 { b.WriteString(`

记录 ` + html.EscapeString(fmt.Sprint(i+1)) + `

`) } b.WriteString(`
`) for _, key := range keys { value := stringify(record[key]) b.WriteString(``) } b.WriteString(`
` + html.EscapeString(key) + ``) if src, ok := imageSrc(key, value); ok { b.WriteString(`` + html.EscapeString(key) + ``) b.WriteString(`
` + html.EscapeString(value) + `
`) } else { b.WriteString(html.EscapeString(value)) } b.WriteString(`
`) } return 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 firstField(obj map[string]any, keys ...string) string { for _, key := range keys { if v, ok := obj[key]; ok { if s := strings.TrimSpace(stringify(v)); s != "" { return s } } } return "" } func qrImageDataURL(text string) (string, error) { text = strings.TrimSpace(text) if text == "" { return "", errors.New("记录里没有可生成二维码的备注字段。") } png, err := qrcode.Encode(text, qrcode.Medium, 220) if err != nil { return "", fmt.Errorf("二维码生成失败:%w", err) } return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil } 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}}
原始 JSON
{{.RawJSON}}
{{end}}
`))