Add Jintai visitor proxy server

main
wangqiyang 3 weeks ago
parent 73b156b488
commit 9e431f2210

@ -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(`<div class="empty">返回内容不是 JSON已显示在原始响应里。</div>`)
}
var b strings.Builder
b.WriteString(`<div class="result-block">`)
b.WriteString(renderAny(value, "查询结果"))
b.WriteString(`</div>`)
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(`<div class="api-message">` + html.EscapeString(msg) + `</div>`)
}
b.WriteString(renderAny(result, "访客记录"))
return b.String()
}
return renderObject(x, title)
case []any:
if len(x) == 0 {
return `<div class="empty">没有查到访客记录。</div>`
}
var b strings.Builder
for i, item := range x {
b.WriteString(renderAny(item, fmt.Sprintf("记录 %d", i+1)))
}
return b.String()
case string:
return `<div class="single-value">` + html.EscapeString(x) + `</div>`
default:
return `<div class="single-value">` + html.EscapeString(fmt.Sprint(x)) + `</div>`
}
}
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(`<article class="record">`)
b.WriteString(`<h2>` + html.EscapeString(title) + `</h2>`)
b.WriteString(`<div class="fields">`)
for _, k := range keys {
value := stringify(obj[k])
b.WriteString(`<div class="field">`)
b.WriteString(`<div class="field-key">` + html.EscapeString(k) + `</div>`)
b.WriteString(`<div class="field-value">`)
if src, ok := imageSrc(k, value); ok {
b.WriteString(`<img class="result-image" src="` + html.EscapeString(src) + `" alt="` + html.EscapeString(k) + `">`)
b.WriteString(`<div class="image-text">` + html.EscapeString(value) + `</div>`)
} else {
b.WriteString(html.EscapeString(value))
}
b.WriteString(`</div></div>`)
}
b.WriteString(`</div></article>`)
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(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>访</title>
<style>
:root {
--bg: #f4f7fb;
--panel: #fff;
--text: #1f2937;
--muted: #64748b;
--line: #dbe4ef;
--primary: #0877d8;
--primary-dark: #075da9;
--ok: #047857;
--err: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background: linear-gradient(180deg, rgba(8, 119, 216, .12), rgba(8, 119, 216, 0) 280px), var(--bg);
}
main { width: min(760px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 44px; }
h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.25; letter-spacing: 0; }
.sub { margin: 0 0 18px; color: var(--muted); font-size: 14px; line-height: 1.7; }
.panel, .record {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 16px 45px rgba(31, 41, 55, .08);
overflow: hidden;
}
.row { padding: 16px; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: 0; }
label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 14px; }
input {
width: 100%;
height: 44px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0 12px;
font-size: 16px;
outline: none;
}
input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(8,119,216,.14); }
.id-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 14px; }
.id-line strong { color: var(--text); font-size: 16px; }
.captcha-grid { display: grid; grid-template-columns: 1fr 152px; gap: 10px; align-items: center; }
.captcha-img {
height: 44px;
border: 1px solid var(--line);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: #fbfdff;
overflow: hidden;
}
.captcha-img img { max-width: 100%; max-height: 42px; display: block; }
button {
width: 100%;
height: 44px;
border: 0;
border-radius: 6px;
color: #fff;
background: var(--primary);
font-size: 15px;
cursor: pointer;
}
button:hover { background: var(--primary-dark); }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.secondary {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
border-radius: 6px;
color: var(--primary);
background: #eaf4ff;
text-decoration: none;
font-size: 15px;
}
.secondary:hover { background: #d8ebff; }
.status { font-size: 14px; line-height: 1.7; }
.status.ok { color: var(--ok); }
.status.err { color: var(--err); }
.status.muted { color: var(--muted); }
.result-block { margin-top: 18px; }
.api-message, .empty, .single-value {
padding: 14px 16px;
color: var(--muted);
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 12px;
}
.record { margin-bottom: 12px; box-shadow: none; }
.record h2 {
margin: 0;
padding: 14px 16px;
font-size: 17px;
border-bottom: 1px solid var(--line);
}
.fields { display: grid; grid-template-columns: 160px 1fr; }
.field { display: contents; }
.field-key, .field-value {
padding: 11px 14px;
border-bottom: 1px solid var(--line);
line-height: 1.55;
word-break: break-word;
white-space: pre-wrap;
font-size: 14px;
}
.field-key { color: var(--muted); background: #f8fafc; }
.field-value { color: var(--text); }
.result-image {
display: block;
max-width: min(240px, 100%);
max-height: 240px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
margin-bottom: 8px;
}
.image-text { color: var(--muted); font-size: 12px; }
pre {
margin: 18px 0 0;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.55;
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
padding: 14px;
}
@media (max-width: 560px) {
.captcha-grid, .actions, .fields { grid-template-columns: 1fr; }
.field-key { border-bottom: 0; padding-bottom: 4px; }
.field-value { padding-top: 4px; }
}
</style>
</head>
<body>
<main>
<h1>访</h1>
<p class="sub"> Go 127.0.0.1访</p>
<section class="panel">
<div class="row">
<div class="id-line">
<span></span>
<strong>{{.IDMasked}}</strong>
</div>
</div>
<form method="get" action="/">
<div class="row">
<label for="date">访</label>
<input id="date" name="date" value="{{.VisitDate}}" placeholder="YYYY-MM-DD" autocomplete="off">
</div>
<div class="row">
<label for="captcha"></label>
<div class="captcha-grid">
<input id="captcha" name="captcha" value="{{.Captcha}}" placeholder="请输入右侧验证码" autocomplete="off">
<div class="captcha-img">
{{if .CaptchaImage}}<img src="{{.CaptchaImage}}" alt="验证码">{{else}}{{end}}
</div>
</div>
</div>
<div class="row">
<div class="actions">
<a class="secondary" href="/"></a>
<button type="submit"></button>
</div>
</div>
</form>
<div class="row">
<div class="status {{.StatusClass}}">{{.StatusMessage}}</div>
</div>
</section>
{{.ResultHTML}}
{{if .RawJSON}}<pre>{{.RawJSON}}</pre>{{end}}
</main>
</body>
</html>`))
Loading…
Cancel
Save