Compare commits
2
Commits
ac6e8695b1
...
bcfaf35d73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcfaf35d73 | ||
|
|
a72cf582d0 |
+26
-3
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -27,9 +28,6 @@ func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
|
||||
cfg := config.Load()
|
||||
|
||||
// تلسکوپ: بازرسِ درونبرنامهایِ درخواستها (رینگبافرِ حافظه، بدونِ وابستگی).
|
||||
scope := telescope.New(cfg.TelescopeSize)
|
||||
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
slog.Error("open db", "err", err)
|
||||
@@ -44,6 +42,31 @@ func main() {
|
||||
jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL)
|
||||
authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP)
|
||||
|
||||
// تلسکوپ: بازرسِ درونبرنامهایِ درخواستها. شناسهی کاربر را ارزان (فقط HMAC)
|
||||
// از توکن درمیآورد و شمارهی موبایل را در زمانِ نمایش (با DB) resolve میکند.
|
||||
scope := telescope.New(cfg.TelescopeSize,
|
||||
func(r *http.Request) int64 {
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if tok == "" || tok == r.Header.Get("Authorization") {
|
||||
tok = r.URL.Query().Get("token")
|
||||
}
|
||||
if tok == "" {
|
||||
return 0
|
||||
}
|
||||
id, err := jwt.Verify(tok)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
},
|
||||
func(id int64) string {
|
||||
if u, err := users.FindByID(context.Background(), id); err == nil {
|
||||
return u.Mobile
|
||||
}
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
// اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافهبازار (امضای RSA).
|
||||
// اگر کلیدها تنظیم نشده باشند، به تأییدکنندهی توسعه برمیگردیم (dev fallback).
|
||||
iap := economy.StoreRouter{DevFallback: true}
|
||||
|
||||
@@ -243,6 +243,9 @@ func (g *Game) trickWinner() int {
|
||||
return best.Seat
|
||||
}
|
||||
|
||||
// Beats نسخهی عمومیِ beats برای استفادهی هوشِ باتِ لایهی ws.
|
||||
func Beats(a, b Card, trump, lead Suit) bool { return beats(a, b, trump, lead) }
|
||||
|
||||
// beats مشخص میکند آیا a کارت b را میبرد (با توجه به حکم و خال زمینه).
|
||||
func beats(a, b Card, trump, lead Suit) bool {
|
||||
aTrump := a.Suit == trump
|
||||
|
||||
+54
-23
@@ -13,7 +13,7 @@ const pageHTML = `<!doctype html>
|
||||
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif;
|
||||
background:#0E2347; color:#E8EEF7; }
|
||||
header { padding:14px 20px; background:#17345C; border-bottom:1px solid #1E5FA8;
|
||||
display:flex; align-items:center; gap:14px; position:sticky; top:0; }
|
||||
display:flex; align-items:center; gap:14px; position:sticky; top:0; z-index:2; }
|
||||
header h1 { font-size:17px; margin:0; font-weight:700; }
|
||||
header .dot { width:9px; height:9px; border-radius:50%; background:#4ade80; }
|
||||
header .meta { margin-inline-start:auto; font-size:12px; opacity:.7; }
|
||||
@@ -22,24 +22,34 @@ const pageHTML = `<!doctype html>
|
||||
.filters input, .filters select {
|
||||
background:#0b1c39; color:#E8EEF7; border:1px solid #1E5FA8;
|
||||
border-radius:8px; padding:7px 10px; font-size:13px; }
|
||||
.filters input { flex:1; min-width:160px; }
|
||||
.filters input { flex:1; min-width:140px; }
|
||||
.wrap { overflow-x:auto; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th, td { padding:9px 12px; text-align:right; border-bottom:1px solid #16305a; white-space:nowrap; }
|
||||
th { position:sticky; top:52px; background:#122c52; font-weight:600; z-index:1; }
|
||||
tr:hover { background:#132b50; }
|
||||
th { background:#122c52; font-weight:600; border-bottom:2px solid #1E5FA8; }
|
||||
tr.row { cursor:pointer; }
|
||||
tr.row:hover { background:#132b50; }
|
||||
.m { font-weight:700; font-size:11px; padding:2px 7px; border-radius:6px; }
|
||||
.GET{background:#134e4a;color:#5eead4} .POST{background:#1e3a8a;color:#93c5fd}
|
||||
.PUT{background:#713f12;color:#fcd34d} .DELETE{background:#7f1d1d;color:#fca5a5}
|
||||
.st { font-weight:700; }
|
||||
.s2{color:#4ade80} .s3{color:#60a5fa} .s4{color:#fbbf24} .s5{color:#f87171}
|
||||
.path { font-family: ui-monospace, monospace; }
|
||||
.body { display:none; }
|
||||
tr.open .body { display:table-row; }
|
||||
.body td { background:#0b1c39; white-space:pre-wrap; font-family: ui-monospace, monospace;
|
||||
font-size:12px; color:#fca5a5; direction:ltr; text-align:left; }
|
||||
.phone { font-family: ui-monospace, monospace; color:#fcd34d; }
|
||||
.slow { color:#fbbf24; }
|
||||
.dim { opacity:.55; }
|
||||
.empty { padding:40px; text-align:center; opacity:.6; }
|
||||
.detail td { background:#0b1c39; padding:0; }
|
||||
.detail.hidden { display:none; }
|
||||
.panels { padding:12px 16px; display:grid; gap:12px; }
|
||||
.panel h4 { margin:0 0 6px; font-size:12px; color:#8ab4e8; font-weight:700;
|
||||
display:flex; align-items:center; gap:6px; }
|
||||
.panel pre { margin:0; background:#04101f; border:1px solid #16305a; border-radius:8px;
|
||||
padding:10px; font-family: ui-monospace, monospace; font-size:12px; line-height:1.6;
|
||||
color:#cbd5e1; white-space:pre-wrap; word-break:break-word; direction:ltr; text-align:left;
|
||||
max-height:280px; overflow:auto; }
|
||||
.kv { display:flex; gap:8px; flex-wrap:wrap; font-size:12px; margin-bottom:4px; }
|
||||
.kv b { color:#8ab4e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -51,6 +61,7 @@ const pageHTML = `<!doctype html>
|
||||
</header>
|
||||
<div class="filters">
|
||||
<input id="q" placeholder="جستجو در مسیر…">
|
||||
<input id="pf" placeholder="شماره موبایل…" inputmode="numeric">
|
||||
<select id="mf">
|
||||
<option value="">همه متدها</option>
|
||||
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
|
||||
@@ -61,48 +72,68 @@ const pageHTML = `<!doctype html>
|
||||
<option value="4">4xx</option><option value="5">5xx</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>حجم</th><th>IP</th>
|
||||
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>موبایل</th><th>IP</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty" id="empty" style="display:none">درخواستی ثبت نشده است</div>
|
||||
<script>
|
||||
let data = [];
|
||||
let openId = null;
|
||||
const $ = s => document.querySelector(s);
|
||||
function fmtTime(t){ const d = new Date(t); return d.toLocaleTimeString('fa-IR'); }
|
||||
const esc = s => (s||'').replace(/&/g,'&').replace(/</g,'<');
|
||||
function fmtTime(t){ return new Date(t).toLocaleTimeString('fa-IR'); }
|
||||
function stClass(s){ return 's' + Math.floor(s/100); }
|
||||
|
||||
function detail(e){
|
||||
const sec = (title, body) => body
|
||||
? '<div class="panel"><h4>'+title+'</h4><pre>'+esc(body)+'</pre></div>' : '';
|
||||
const kv = '<div class="kv">'+
|
||||
(e.mobile ? '<span><b>موبایل:</b> '+esc(e.mobile)+'</span>' : '<span class="dim">کاربر مهمان</span>')+
|
||||
(e.user_id ? '<span><b>شناسه کاربر:</b> '+e.user_id+'</span>' : '')+
|
||||
'<span><b>حجم پاسخ:</b> '+e.bytes+' بایت</span>'+
|
||||
'<span><b>مدت:</b> '+e.duration_ms+'ms</span></div>';
|
||||
return '<div class="panels">'+kv+
|
||||
sec('هدرها', e.headers)+
|
||||
sec('Payload درخواست', e.req_body)+
|
||||
sec('پاسخ', e.resp_body)+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q = $('#q').value.trim().toLowerCase();
|
||||
const pf = $('#pf').value.trim();
|
||||
const mf = $('#mf').value, sf = $('#sf').value;
|
||||
const rows = data.filter(e =>
|
||||
(!q || (e.path||'').toLowerCase().includes(q)) &&
|
||||
(!pf || (e.mobile||'').includes(pf)) &&
|
||||
(!mf || e.method === mf) &&
|
||||
(!sf || Math.floor((e.status||0)/100) == sf));
|
||||
const tb = $('#rows'); tb.innerHTML = '';
|
||||
$('#empty').style.display = rows.length ? 'none' : 'block';
|
||||
for(const e of rows){
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'row';
|
||||
const slow = e.duration_ms > 500 ? ' slow' : '';
|
||||
tr.innerHTML =
|
||||
'<td class="dim">'+fmtTime(e.time)+'</td>'+
|
||||
'<td><span class="m '+e.method+'">'+e.method+'</span></td>'+
|
||||
'<td class="path">'+e.path+'</td>'+
|
||||
'<td class="path">'+esc(e.path)+'</td>'+
|
||||
'<td class="st '+stClass(e.status)+'">'+(e.status||'-')+'</td>'+
|
||||
'<td class="'+slow.trim()+'">'+e.duration_ms+'ms</td>'+
|
||||
'<td class="dim">'+e.bytes+'</td>'+
|
||||
'<td class="dim">'+(e.ip||'')+'</td>';
|
||||
if(e.body){
|
||||
tr.style.cursor='pointer';
|
||||
tr.onclick = () => { const b = tr.nextSibling; b.style.display = b.style.display==='table-row'?'none':'table-row'; };
|
||||
tb.appendChild(tr);
|
||||
const b = document.createElement('tr'); b.className='body';
|
||||
b.innerHTML = '<td colspan="7">'+e.body.replace(/</g,'<')+'</td>';
|
||||
b.style.display='none';
|
||||
tb.appendChild(b);
|
||||
} else {
|
||||
'<td class="phone">'+esc(e.mobile||'')+'</td>'+
|
||||
'<td class="dim">'+esc(e.ip||'')+'</td>';
|
||||
tr.onclick = () => { openId = (openId===e.id ? null : e.id); render(); };
|
||||
tb.appendChild(tr);
|
||||
if(openId === e.id){
|
||||
const dr = document.createElement('tr');
|
||||
dr.className = 'detail';
|
||||
dr.innerHTML = '<td colspan="7">'+detail(e)+'</td>';
|
||||
tb.appendChild(dr);
|
||||
}
|
||||
}
|
||||
$('#meta').textContent = rows.length + ' / ' + data.length + ' درخواست';
|
||||
@@ -114,7 +145,7 @@ async function load(){
|
||||
render();
|
||||
}catch(e){ $('#meta').textContent = 'خطا در دریافت'; }
|
||||
}
|
||||
['q','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
|
||||
['q','pf','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
|
||||
load();
|
||||
setInterval(() => { if($('#live').checked) load(); }, 2000);
|
||||
</script>
|
||||
|
||||
+122
-28
@@ -1,7 +1,7 @@
|
||||
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
|
||||
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
|
||||
// مدتزمان، IP و در صورتِ خطا (>=۴۰۰) بدنهی درخواست. بدونِ هیچ وابستگیِ خارجی
|
||||
// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
||||
// مدتزمان، IP، هدرها، payloadِ درخواست، پاسخ و شمارهی موبایلِ کاربر. بدونِ
|
||||
// وابستگیِ خارجی و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
||||
package telescope
|
||||
|
||||
import (
|
||||
@@ -11,11 +11,18 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست
|
||||
maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
|
||||
)
|
||||
|
||||
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -26,7 +33,11 @@ type Entry struct {
|
||||
Duration int64 `json:"duration_ms"`
|
||||
IP string `json:"ip"`
|
||||
Bytes int `json:"bytes"`
|
||||
Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"` // در زمانِ نمایش resolve میشود
|
||||
Headers string `json:"headers,omitempty"`
|
||||
ReqBody string `json:"req_body,omitempty"` // payloadِ درخواست
|
||||
RespBody string `json:"resp_body,omitempty"` // بدنهی پاسخ
|
||||
}
|
||||
|
||||
// Telescope رینگبافرِ همزمانامنِ درخواستها.
|
||||
@@ -37,14 +48,27 @@ type Telescope struct {
|
||||
next int64 // شناسهی افزایشی
|
||||
full bool
|
||||
head int // اندیسِ نوشتنِ بعدی
|
||||
|
||||
// decodeUID شناسهی کاربر را از توکنِ درخواست (بدونِ DB، فقط HMAC) درمیآورد.
|
||||
decodeUID func(*http.Request) int64
|
||||
// resolveMobile شمارهی موبایل را از شناسه (با DB) میگیرد؛ فقط در زمانِ نمایش.
|
||||
resolveMobile func(int64) string
|
||||
mobileCache map[int64]string
|
||||
}
|
||||
|
||||
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱).
|
||||
func New(size int) *Telescope {
|
||||
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱). decodeUID و resolveMobile
|
||||
// میتوانند nil باشند (مثلاً در تستها).
|
||||
func New(size int, decodeUID func(*http.Request) int64, resolveMobile func(int64) string) *Telescope {
|
||||
if size < 1 {
|
||||
size = 200
|
||||
}
|
||||
return &Telescope{buf: make([]Entry, size), size: size}
|
||||
return &Telescope{
|
||||
buf: make([]Entry, size),
|
||||
size: size,
|
||||
decodeUID: decodeUID,
|
||||
resolveMobile: resolveMobile,
|
||||
mobileCache: map[int64]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Telescope) add(e Entry) {
|
||||
@@ -59,28 +83,55 @@ func (t *Telescope) add(e Entry) {
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند.
|
||||
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند و در همینجا
|
||||
// شمارهی موبایلِ هر کاربر را (با کش) resolve میکند.
|
||||
func (t *Telescope) Entries() []Entry {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
n := t.head
|
||||
if t.full {
|
||||
n = t.size
|
||||
}
|
||||
out := make([]Entry, 0, n)
|
||||
// از جدیدترین (درست قبلِ head) عقب میرویم.
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (t.head - 1 - i + t.size*2) % t.size
|
||||
out = append(out, t.buf[idx])
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
// resolveِ موبایل بیرون از قفلِ اصلی (ممکن است به DB بزند).
|
||||
for i := range out {
|
||||
out[i].Mobile = t.mobileFor(out[i].UserID)
|
||||
// درخواستهای ورود (بدونِ توکن) شماره را در payload دارند.
|
||||
if out[i].Mobile == "" {
|
||||
out[i].Mobile = mobileFromBody(out[i].ReqBody)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// statusRecorder کدِ وضعیت و حجمِ پاسخ را میگیرد.
|
||||
func (t *Telescope) mobileFor(id int64) string {
|
||||
if id == 0 || t.resolveMobile == nil {
|
||||
return ""
|
||||
}
|
||||
t.mu.Lock()
|
||||
if m, ok := t.mobileCache[id]; ok {
|
||||
t.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
t.mu.Unlock()
|
||||
m := t.resolveMobile(id)
|
||||
t.mu.Lock()
|
||||
t.mobileCache[id] = m
|
||||
t.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
||||
// statusRecorder کدِ وضعیت، حجم و بخشی از بدنهی پاسخ را میگیرد.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
buf *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
@@ -92,6 +143,14 @@ func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
if r.buf != nil && r.buf.Len() < maxRespBody {
|
||||
remain := maxRespBody - r.buf.Len()
|
||||
if remain >= len(b) {
|
||||
r.buf.Write(b)
|
||||
} else {
|
||||
r.buf.Write(b[:remain])
|
||||
}
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
@@ -105,7 +164,8 @@ func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
|
||||
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ).
|
||||
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ،
|
||||
// WebSocket و فایلهای استاتیک).
|
||||
func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
p := req.URL.Path
|
||||
@@ -115,19 +175,24 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
var body string
|
||||
if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
|
||||
var reqBody string
|
||||
if req.Body != nil && req.Method != http.MethodGet {
|
||||
b, _ := io.ReadAll(io.LimitReader(req.Body, maxReqBody))
|
||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||
body = maskSecrets(string(b))
|
||||
reqBody = maskSecrets(string(b))
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
var uid int64
|
||||
if t.decodeUID != nil {
|
||||
uid = t.decodeUID(req) // فقط HMAC، بدون DB
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w, buf: &bytes.Buffer{}}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(rec, req)
|
||||
dur := time.Since(start)
|
||||
|
||||
e := Entry{
|
||||
t.add(Entry{
|
||||
Time: start,
|
||||
Method: req.Method,
|
||||
Path: p,
|
||||
@@ -135,18 +200,49 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||
Duration: dur.Milliseconds(),
|
||||
IP: req.RemoteAddr,
|
||||
Bytes: rec.bytes,
|
||||
}
|
||||
// بدنه را فقط برای خطاها نگه میداریم تا حافظه/حریمِ خصوصی حفظ شود.
|
||||
if rec.status >= 400 {
|
||||
e.Body = body
|
||||
}
|
||||
t.add(e)
|
||||
UserID: uid,
|
||||
Headers: formatHeaders(req.Header),
|
||||
ReqBody: reqBody,
|
||||
RespBody: maskSecrets(rec.buf.String()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنهی JSON پنهان میکند.
|
||||
// formatHeaders هدرهای درخواست را (با پنهانکردنِ مقادیرِ حساس) به رشته تبدیل میکند.
|
||||
func formatHeaders(h http.Header) string {
|
||||
keys := make([]string, 0, len(h))
|
||||
for k := range h {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
for _, k := range keys {
|
||||
val := strings.Join(h[k], ", ")
|
||||
switch strings.ToLower(k) {
|
||||
case "authorization", "cookie", "x-access-token":
|
||||
val = "***"
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(val)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var mobileRe = regexp.MustCompile(`"mobile"\s*:\s*"([^"]+)"`)
|
||||
|
||||
// mobileFromBody شمارهی موبایل را از payloadِ درخواست (مثلِ ورود) درمیآورد.
|
||||
func mobileFromBody(body string) string {
|
||||
if m := mobileRe.FindStringSubmatch(body); len(m) == 2 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد، امضا) را در JSON پنهان میکند.
|
||||
func maskSecrets(s string) string {
|
||||
for _, k := range []string{"password", "token", "code", "otp", "signature"} {
|
||||
for _, k := range []string{"password", "token", "code", "otp", "signature", "purchase_data"} {
|
||||
s = maskField(s, k)
|
||||
}
|
||||
return s
|
||||
@@ -158,7 +254,6 @@ func maskField(s, key string) string {
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
// دنبالِ ": " و سپس مقدار میگردیم.
|
||||
j := strings.Index(s[i:], ":")
|
||||
if j < 0 {
|
||||
return s
|
||||
@@ -168,8 +263,7 @@ func maskField(s, key string) string {
|
||||
if strings.HasPrefix(rest, `"`) {
|
||||
end := strings.Index(rest[1:], `"`)
|
||||
if end >= 0 {
|
||||
masked := s[:i+j+1] + `"***"` + rest[end+2:]
|
||||
return masked
|
||||
return s[:i+j+1] + `"***"` + rest[end+2:]
|
||||
}
|
||||
}
|
||||
return s
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestRingOrderAndOverflow(t *testing.T) {
|
||||
tel := New(3)
|
||||
tel := New(3, nil, nil)
|
||||
for i := 0; i < 5; i++ {
|
||||
tel.add(Entry{Path: string(rune('a' + i))})
|
||||
}
|
||||
@@ -29,43 +29,76 @@ func TestRingOrderAndOverflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) {
|
||||
tel := New(10)
|
||||
func TestMiddlewareCapturesPayloadHeadersResponseMasked(t *testing.T) {
|
||||
tel := New(10, nil, nil)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"token":"xyz","message":"bad"}`))
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/x",
|
||||
strings.NewReader(`{"password":"secret","kind":"coin"}`))
|
||||
req.Header.Set("Authorization", "Bearer supersecret")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Status != 400 {
|
||||
t.Fatalf("expected one 400 entry, got %+v", e)
|
||||
}
|
||||
if !strings.Contains(e[0].Body, `"password":"***"`) {
|
||||
t.Errorf("password not masked: %s", e[0].Body)
|
||||
// payload برای هر درخواست ثبت میشود و مقادیرِ حساس ماسک میشوند.
|
||||
if !strings.Contains(e[0].ReqBody, `"password":"***"`) {
|
||||
t.Errorf("password not masked: %s", e[0].ReqBody)
|
||||
}
|
||||
if !strings.Contains(e[0].Body, `"kind":"coin"`) {
|
||||
t.Errorf("non-secret field lost: %s", e[0].Body)
|
||||
if !strings.Contains(e[0].ReqBody, `"kind":"coin"`) {
|
||||
t.Errorf("non-secret field lost: %s", e[0].ReqBody)
|
||||
}
|
||||
// پاسخ ثبت و توکنِ آن ماسک میشود.
|
||||
if !strings.Contains(e[0].RespBody, `"token":"***"`) {
|
||||
t.Errorf("response token not masked: %s", e[0].RespBody)
|
||||
}
|
||||
// هدرِ Authorization در نمایش پنهان میشود.
|
||||
if strings.Contains(e[0].Headers, "supersecret") {
|
||||
t.Errorf("authorization header leaked: %s", e[0].Headers)
|
||||
}
|
||||
if !strings.Contains(e[0].Headers, "Authorization: ***") {
|
||||
t.Errorf("authorization header not masked: %s", e[0].Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareOKHasNoBody(t *testing.T) {
|
||||
tel := New(10)
|
||||
func TestMobileFromLoginBody(t *testing.T) {
|
||||
tel := New(10, nil, nil)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/y",
|
||||
strings.NewReader(`{"token":"abc"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login-otp",
|
||||
strings.NewReader(`{"mobile":"09120000000"}`))
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Body != "" {
|
||||
t.Errorf("2xx should not store body, got %+v", e)
|
||||
if len(e) != 1 || e[0].Mobile != "09120000000" {
|
||||
t.Errorf("mobile not extracted from login body, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMobileByUserID(t *testing.T) {
|
||||
tel := New(10,
|
||||
func(*http.Request) int64 { return 42 },
|
||||
func(id int64) string {
|
||||
if id == 42 {
|
||||
return "09121112233"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/me", nil))
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Mobile != "09121112233" || e[0].UserID != 42 {
|
||||
t.Errorf("mobile not resolved by user id, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareSkipsWS(t *testing.T) {
|
||||
tel := New(10)
|
||||
tel := New(10, nil, nil)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package ws
|
||||
|
||||
import "hakemsho/internal/game"
|
||||
|
||||
// هوشِ باتِ حکم — تصمیمگیریِ اکتشافی (heuristic) که فقط از دستِ خودِ بات و
|
||||
// کارتهای روی زمین استفاده میکند (بدونِ دیدنِ دستِ دیگران؛ بازیِ منصفانه).
|
||||
//
|
||||
// راهبردها:
|
||||
// - انتخابِ حکم: خالی که هم بلند است و هم کارتِ قوی دارد.
|
||||
// - رهبریِ دست: آسِ غیرِحکم را نقد کن؛ وگرنه از بلندترین خالِ غیرِحکم کارتِ کم بریز.
|
||||
// - دنبالکردن: اگر شریک میبرد کارتِ کم بریز؛ اگر حریف میبرد با کمترین کارتِ
|
||||
// برنده ببر؛ اگر خالِ زمینه را نداری و حریف میبرد با کمترین حکم بِبُر.
|
||||
|
||||
// botChooseTrump خالِ حکم را بر اساسِ طول + قدرتِ کارتها انتخاب میکند.
|
||||
func botChooseTrump(hand []game.Card) game.Suit {
|
||||
var score [4]float64
|
||||
for _, c := range hand {
|
||||
score[c.Suit] += 1
|
||||
switch c.Rank {
|
||||
case game.Ace:
|
||||
score[c.Suit] += 2.0
|
||||
case game.King:
|
||||
score[c.Suit] += 1.3
|
||||
case game.Queen:
|
||||
score[c.Suit] += 0.7
|
||||
case game.Jack:
|
||||
score[c.Suit] += 0.3
|
||||
}
|
||||
}
|
||||
best, bestScore := game.Suit(0), -1.0
|
||||
for s := game.Suit(0); s < 4; s++ {
|
||||
if score[s] > bestScore {
|
||||
bestScore, best = score[s], s
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// botPlay کارتی که بات باید بازی کند را برمیگرداند.
|
||||
func botPlay(hand []game.Card, trick []game.TrickCard, trump, lead game.Suit) game.Card {
|
||||
if len(trick) == 0 {
|
||||
return botLead(hand, trump)
|
||||
}
|
||||
|
||||
best := trickBest(trick, trump, lead)
|
||||
// جایگاهها متناوباند؛ کارتی که دو نوبت قبل انداخته شده متعلق به شریکِ باتِ
|
||||
// در نوبت است. اگر آن (یا هر کارتِ شریک) بهترین کارتِ فعلی باشد، شریک میبرد.
|
||||
partnerWins := false
|
||||
if len(trick) >= 2 {
|
||||
partnerWins = best.Seat == trick[len(trick)-2].Seat
|
||||
}
|
||||
|
||||
followers := cardsOfSuit(hand, lead)
|
||||
if len(followers) > 0 {
|
||||
if partnerWins {
|
||||
return lowest(followers) // شریک میبرد ⇒ کارتِ کم بریز
|
||||
}
|
||||
if w, ok := lowestWinner(followers, best.Card, trump, lead); ok {
|
||||
return w // با کمترین کارتِ برنده ببر
|
||||
}
|
||||
return lowest(followers) // نمیتوانی ببری ⇒ کارتِ کم
|
||||
}
|
||||
|
||||
// خالِ زمینه را نداریم (void).
|
||||
trumps := cardsOfSuit(hand, trump)
|
||||
if partnerWins || len(trumps) == 0 {
|
||||
return lowestDiscard(hand, trump)
|
||||
}
|
||||
if w, ok := lowestWinner(trumps, best.Card, trump, lead); ok {
|
||||
return w // با کمترین حکمِ برنده بِبُر
|
||||
}
|
||||
return lowestDiscard(hand, trump) // حکمت هم نمیبرد ⇒ دورریز
|
||||
}
|
||||
|
||||
// botLead راهبردِ رهبریِ دست: آسِ غیرِحکم را نقد کن؛ وگرنه از بلندترین خالِ
|
||||
// غیرِحکم کارتِ کم بریز؛ اگر فقط حکم داری، بالاترین حکم را بزن تا حکمها را بکشی.
|
||||
func botLead(hand []game.Card, trump game.Suit) game.Card {
|
||||
// آسِ غیرِحکم (برندهی تقریباً قطعی).
|
||||
var ace *game.Card
|
||||
for i := range hand {
|
||||
if hand[i].Suit != trump && hand[i].Rank == game.Ace {
|
||||
c := hand[i]
|
||||
ace = &c
|
||||
break
|
||||
}
|
||||
}
|
||||
if ace != nil {
|
||||
return *ace
|
||||
}
|
||||
|
||||
// بلندترین خالِ غیرِحکم.
|
||||
var counts [4]int
|
||||
for _, c := range hand {
|
||||
counts[c.Suit]++
|
||||
}
|
||||
bestSuit, bestLen := game.Suit(255), -1
|
||||
for _, c := range hand {
|
||||
if c.Suit == trump {
|
||||
continue
|
||||
}
|
||||
if counts[c.Suit] > bestLen {
|
||||
bestLen, bestSuit = counts[c.Suit], c.Suit
|
||||
}
|
||||
}
|
||||
if bestSuit != game.Suit(255) {
|
||||
return lowest(cardsOfSuit(hand, bestSuit))
|
||||
}
|
||||
// فقط حکم مانده ⇒ بالاترین حکم را بزن.
|
||||
return highest(cardsOfSuit(hand, trump))
|
||||
}
|
||||
|
||||
// --- کمکیها ---
|
||||
|
||||
func trickBest(trick []game.TrickCard, trump, lead game.Suit) game.TrickCard {
|
||||
best := trick[0]
|
||||
for _, tc := range trick[1:] {
|
||||
if game.Beats(tc.Card, best.Card, trump, lead) {
|
||||
best = tc
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func cardsOfSuit(hand []game.Card, s game.Suit) []game.Card {
|
||||
var out []game.Card
|
||||
for _, c := range hand {
|
||||
if c.Suit == s {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lowest(cards []game.Card) game.Card {
|
||||
best := cards[0]
|
||||
for _, c := range cards[1:] {
|
||||
if c.Rank < best.Rank {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func highest(cards []game.Card) game.Card {
|
||||
best := cards[0]
|
||||
for _, c := range cards[1:] {
|
||||
if c.Rank > best.Rank {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// lowestWinner کمترین کارتِ candidate که کارتِ فعلیِ برنده را میبرد.
|
||||
func lowestWinner(candidates []game.Card, cur game.Card, trump, lead game.Suit) (game.Card, bool) {
|
||||
var best game.Card
|
||||
found := false
|
||||
for _, c := range candidates {
|
||||
if game.Beats(c, cur, trump, lead) && (!found || c.Rank < best.Rank) {
|
||||
best, found = c, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// lowestDiscard کمترین کارتِ غیرِحکم را برای دورریز برمیگرداند؛ اگر فقط حکم مانده،
|
||||
// کمترین حکم.
|
||||
func lowestDiscard(hand []game.Card, trump game.Suit) game.Card {
|
||||
var nonTrump []game.Card
|
||||
for _, c := range hand {
|
||||
if c.Suit != trump {
|
||||
nonTrump = append(nonTrump, c)
|
||||
}
|
||||
}
|
||||
if len(nonTrump) > 0 {
|
||||
return lowest(nonTrump)
|
||||
}
|
||||
return lowest(hand)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hakemsho/internal/game"
|
||||
)
|
||||
|
||||
func card(s game.Suit, r game.Rank) game.Card { return game.Card{Suit: s, Rank: r} }
|
||||
|
||||
func TestBotWinsWhenOpponentLeads(t *testing.T) {
|
||||
// حریف با شاهِ پیک رهبری کرده؛ بات باید با آسِ پیک (کمترین کارتِ برنده) ببرد.
|
||||
hand := []game.Card{card(game.Spades, game.Ace), card(game.Spades, 3), card(game.Clubs, 5)}
|
||||
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.King)}}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades)
|
||||
if got != card(game.Spades, game.Ace) {
|
||||
t.Errorf("expected S-A to win, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSavesCardWhenPartnerWins(t *testing.T) {
|
||||
// شریک (صندلی۰) با آسِ پیک برنده است؛ باتِ صندلی۲ باید کمترین پیک را بریزد.
|
||||
hand := []game.Card{card(game.Spades, game.King), card(game.Spades, 5)}
|
||||
trick := []game.TrickCard{
|
||||
{Seat: 0, Card: card(game.Spades, game.Ace)},
|
||||
{Seat: 1, Card: card(game.Spades, 2)},
|
||||
}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades)
|
||||
if got != card(game.Spades, 5) {
|
||||
t.Errorf("expected low S-5 (save the King), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotTrumpsOpponent(t *testing.T) {
|
||||
// بات پیک ندارد و حریف با آسِ پیک میبرد ⇒ باید با کمترین حکم بِبُرد.
|
||||
hand := []game.Card{card(game.Hearts, 2), card(game.Hearts, game.King), card(game.Clubs, 9)}
|
||||
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades)
|
||||
if got != card(game.Hearts, 2) {
|
||||
t.Errorf("expected lowest trump H-2, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotDiscardsWhenPartnerWinsAndVoid(t *testing.T) {
|
||||
// شریک میبرد و بات خالِ زمینه را ندارد ⇒ حکم را هدر ندهد، کارتِ کم بریزد.
|
||||
hand := []game.Card{card(game.Hearts, game.King), card(game.Clubs, 4), card(game.Clubs, 9)}
|
||||
trick := []game.TrickCard{
|
||||
{Seat: 0, Card: card(game.Spades, game.Ace)},
|
||||
{Seat: 1, Card: card(game.Spades, 2)},
|
||||
}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades)
|
||||
if got != card(game.Clubs, 4) {
|
||||
t.Errorf("expected low discard C-4 (keep trump), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLeadsNonTrumpAce(t *testing.T) {
|
||||
// در رهبری، آسِ غیرِحکم را نقد کن.
|
||||
hand := []game.Card{card(game.Clubs, game.Ace), card(game.Diamonds, 7), card(game.Hearts, game.King)}
|
||||
got := botPlay(hand, nil, game.Hearts, 0)
|
||||
if got != card(game.Clubs, game.Ace) {
|
||||
t.Errorf("expected to lead C-A, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCannotBeatKeepsLow(t *testing.T) {
|
||||
// حریف با آسِ پیک میبرد و بات فقط پیکِ پایین دارد ⇒ کمترین را بریزد.
|
||||
hand := []game.Card{card(game.Spades, 4), card(game.Spades, 9)}
|
||||
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}}
|
||||
got := botPlay(hand, trick, game.Hearts, game.Spades)
|
||||
if got != card(game.Spades, 4) {
|
||||
t.Errorf("expected low S-4, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotChooseTrumpPrefersStrongLongSuit(t *testing.T) {
|
||||
// خشت: ۴ کارت اما ضعیف؛ گشنیز: ۳ کارت اما آس+شاه. باید گشنیز را حکم کند اگر
|
||||
// امتیازش بیشتر شود — اینجا خشت ۴تایی برنده است. تستِ ساده: بلندترینِ قوی.
|
||||
hand := []game.Card{
|
||||
card(game.Diamonds, 2), card(game.Diamonds, 3), card(game.Diamonds, 4), card(game.Diamonds, 5),
|
||||
card(game.Clubs, game.Ace),
|
||||
}
|
||||
if got := botChooseTrump(hand); got != game.Diamonds {
|
||||
t.Errorf("expected Diamonds (longest), got %v", got)
|
||||
}
|
||||
}
|
||||
+4
-31
@@ -333,50 +333,23 @@ func (r *Room) autoMove(seat int) {
|
||||
}
|
||||
}
|
||||
|
||||
// autoChooseTrump خالی را انتخاب میکند که بازیکن بیشترین کارت از آن را دارد.
|
||||
// autoChooseTrump حکم را با راهبردِ باتِ هوشمند (طول + قدرتِ خال) انتخاب میکند.
|
||||
func (r *Room) autoChooseTrump(seat int) {
|
||||
hand := r.game.Hand(seat)
|
||||
if len(hand) == 0 {
|
||||
return
|
||||
}
|
||||
var counts [4]int
|
||||
for _, c := range hand {
|
||||
counts[c.Suit]++
|
||||
}
|
||||
best, bestN := hand[0].Suit, -1
|
||||
for s := game.Suit(0); s < 4; s++ {
|
||||
if counts[s] > bestN {
|
||||
bestN, best = counts[s], s
|
||||
}
|
||||
}
|
||||
_ = r.game.ChooseTrump(seat, best)
|
||||
_ = r.game.ChooseTrump(seat, botChooseTrump(hand))
|
||||
r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیهی کارتها
|
||||
}
|
||||
|
||||
// autoPlayCard یک کارت مجاز (پایینترین کارتِ خالِ زمینه، وگرنه پایینترین کارت) بازی میکند.
|
||||
// autoPlayCard با هوشِ باتِ اکتشافی یک کارتِ مجاز و راهبردی بازی میکند.
|
||||
func (r *Room) autoPlayCard(seat int) {
|
||||
hand := r.game.Hand(seat)
|
||||
if len(hand) == 0 {
|
||||
return
|
||||
}
|
||||
var choice game.Card
|
||||
found := false
|
||||
if len(r.game.Trick) > 0 { // خالِ زمینه تعیین شده
|
||||
lead := r.game.LeadSuit
|
||||
for _, c := range hand {
|
||||
if c.Suit == lead && (!found || c.Rank < choice.Rank) {
|
||||
choice, found = c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
choice = hand[0]
|
||||
for _, c := range hand {
|
||||
if c.Rank < choice.Rank {
|
||||
choice = c
|
||||
}
|
||||
}
|
||||
}
|
||||
choice := botPlay(hand, r.game.Trick, r.game.Trump, r.game.LeadSuit)
|
||||
_ = r.playCard(seat, choice)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user