add: chat service

This commit is contained in:
wwweww
2026-04-24 20:43:53 +08:00
parent 4cc4c96b21
commit 756ca20c6d
43 changed files with 3035 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
COPY go-wst/ go-wst/
COPY juwan-backend/go.mod juwan-backend/go.sum juwan-backend/
WORKDIR /build/juwan-backend
RUN go mod download
COPY juwan-backend/ /build/juwan-backend/
RUN CGO_ENABLED=0 go build -o /chat-api ./app/chat/api/
FROM alpine:latest
COPY --from=builder /chat-api /chat-api
COPY juwan-backend/app/chat/test/chat-api-test.yaml /etc/chat-api.yaml
CMD ["/chat-api", "-f", "/etc/chat-api.yaml"]
+18
View File
@@ -0,0 +1,18 @@
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
COPY go-wst/ go-wst/
COPY juwan-backend/go.mod juwan-backend/go.sum juwan-backend/
WORKDIR /build/juwan-backend
RUN go mod download
COPY juwan-backend/ /build/juwan-backend/
RUN CGO_ENABLED=0 go build -o /chat-rpc ./app/chat/rpc/
FROM alpine:latest
COPY --from=builder /chat-rpc /chat-rpc
COPY juwan-backend/app/chat/rpc/etc/pb.yaml /etc/pb.yaml
CMD ["/chat-rpc", "-f", "/etc/pb.yaml"]
+27
View File
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEmjCCAwKgAwIBAgIQP64kUTHSRYb6YJNRMMkJ2DANBgkqhkiG9w0BAQsFADCB
gzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSwwKgYDVQQLDCNhc2Fk
ekBBc2FkemRlTWFjLW1pbmkubG9jYWwgKEFzYWR6KTEzMDEGA1UEAwwqbWtjZXJ0
IGFzYWR6QEFzYWR6ZGVNYWMtbWluaS5sb2NhbCAoQXNhZHopMB4XDTI2MDQyNDA3
NDk0NVoXDTI4MDcyNDA3NDk0NVowVzEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3Bt
ZW50IGNlcnRpZmljYXRlMSwwKgYDVQQLDCNhc2FkekBBc2FkemRlTWFjLW1pbmku
bG9jYWwgKEFzYWR6KTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK96
emv0wyPAnxxMVLMzp7iSOlRtq4ay68xTFOCCMSvgDTek91lyA1AL/zZ558C86dio
9HI43VIy70BwQEzVHdPdg1bPWn02ic4197po+k/xbKUdAxSElM2JdRkr1D6OeTz2
y4jAqL2YZu/ZWR2PZ41TSYEnSc3UKc/ZsdOanF21w5OHpL5cNzJHcQ+8KP4vMEHd
odUwxGbp4D0/Wnd57hSO6M1XywiQRDlJq+atqiPSAG1AlI30T39KNkcfYwv90WgD
t1S8KQhYS5ddP81TUoMymQLczxoQkv4DjG3K4UhnscRNXa3IaVWAkXX1x4eakd3X
jKh9uNCxTtPM+iFKmbUCAwEAAaOBtDCBsTAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0l
BAwwCgYIKwYBBQUHAwEwHwYDVR0jBBgwFoAUYOQEJcqeAhlfeIBUbdPBAFGj9Ogw
aQYDVR0RBGIwYIIJbG9jYWxob3N0gghjaGF0LWFwaYIiY2hhdC1hcGkuZGVmYXVs
dC5zdmMuY2x1c3Rlci5sb2NhbIINKi5qdXdhbi5sb2NhbIcEfwAAAYcQAAAAAAAA
AAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAYEABQeqvTqcNpT9cnTdz0kwNlHW
f6GGfYQ39ZZ1XTwKbFKKner+0Oe+WkoQnMt0sTx/ImOMpC4LAaq08pU0k85d4lQA
yGSv8mnWLyEVFnU02cfeIcMhV6qrl5Od/g4Ow2JRRlMQxg/FRzNtzIIcPwi46K5V
mozMXIf6QOUGa4wPrh7AdybYnA2YPmJJrNCwI2ycHtapmo3T5oO1dm+KWSWbYrx7
yiN6ZBTxaxESJfjPYCrSNXnzRuXrseDIlKYyU0j3GMmbaSOYHVWSTnsB/Mei9Tff
uLHOalyawbsgjqT4xVd7MFXni/mk2FDwJcPH8WAg0KgHZ+M7j8oWkqj5RS8skBFc
EK6Y4PbYRjKUWESQPGBbUwjkjSPYz2KiWz4cnXyL2MnAg1BPUskNrBfPUEWIkpWe
XyljMEOofQBw7G9QFIrQwWD3I8ps+KicskgcUoY62AkGh4Ky0X84tJCIrS3bwkCi
OR681vZWZqPpG3sj7zpmcnAibA0Y7Jpj+9RoR1D9
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvenpr9MMjwJ8c
TFSzM6e4kjpUbauGsuvMUxTggjEr4A03pPdZcgNQC/82eefAvOnYqPRyON1SMu9A
cEBM1R3T3YNWz1p9NonONfe6aPpP8WylHQMUhJTNiXUZK9Q+jnk89suIwKi9mGbv
2Vkdj2eNU0mBJ0nN1CnP2bHTmpxdtcOTh6S+XDcyR3EPvCj+LzBB3aHVMMRm6eA9
P1p3ee4UjujNV8sIkEQ5Savmraoj0gBtQJSN9E9/SjZHH2ML/dFoA7dUvCkIWEuX
XT/NU1KDMpkC3M8aEJL+A4xtyuFIZ7HETV2tyGlVgJF19ceHmpHd14yofbjQsU7T
zPohSpm1AgMBAAECggEAAiAq25343e/WkWN/ObzISJlBoUvz2S5dt93vlimvGWua
1oa+mPoLCnfJl8V2UQMGiqGCRFojLkvMLknEq7pbKsj3gXKU3Ii42z7KQbN2Rh01
cosPsBX2xqGRCuBTTcsqjf7boC24IiIbH5ZvBng9K5OX61PQAmsGEZdsknxez4kz
nnc20PbQ2HlZZ8oOTgmGFoYil2q40Lfj3VOVwaIidFfy8MAZNA6T3tmh6NFvupBy
GPNTLPDqu4b5MUX5UxX/QV+cusK6h5rcyoLdmFY8jAEstIISmtx5HaJXw5oB0+6w
+r5F+LCNVqmS5DUfvfAHiQ5TnACyy8QKsrSnbC1SAwKBgQDBvYgeEOhI5CvLmRe9
JCgHO/Pu14Gg63oQN3uHakjKrUOo8fFXSUMpGJZy7PPeAZJ0ssFJLarEcn+9df25
ksluZbinwJs71rWHp1jZNVHvvHuuPxV+exrwZO7FrIUUjmZCKH8thKcGcNfAT74q
V6WHDeQLzdAnEzibmuZnKHe7OwKBgQDn3pbyxuCgA3aZBOiVvgdVuRoCRyTRV3I8
LbabdpDvr3UvnZy/uPQFaFcmxaasHAy8ZJulbWrLGcDxK8CZRu8xLQY6I53djd95
8v/YIvFmWmTnkMz7qibNqKJjKVWN4WbZcd1b+Wu6kMzkkVsIU36bHoYiReQXuJwq
7lvbV+lPzwKBgQCPEXNPIJUoHrbopqkNF4IntXIxUht7xehhyVcDbM1MPh7Ux7W9
C3D5DBstyyVbMDYCz25Ep+CPKS6Drnora+YsDBoMZwM7cRakkkPeQq27J6j9x8AL
osUF+MMKXpf30iBZgqZH6smcy//HGBwKEKc/0FYzEU1BTcRjxEOYsh2YuQKBgGOw
pvOwoAkMFCSMILeo4RxxHgaWsfSzhTDscpN6savroxWazTb8/SWKC9ZmqldbI/qn
wueoGH9EDllid0cvYU2iTwgWIhyMj+WtnWQ++c0I1lNdRVR6fn5zn4XE0rzSiVa6
BvMxVKj88qre9+WniEqHICKCLCQqwjIPEz1GGdCvAoGAP8fzIc/1esph4FT94SxR
CWYGKskH2/iv7LeB9xI+uS4/oz1hZ9lLhZYfFzYzyGKQjDLLDAI7mFdS30VHjYBu
/lYZOqvs9awQjCXQ0BftU0P2wU+ANBLEZPKxyquZqItQzRavOV3a5/1iI7//vDeN
OWMzztsAP0sRb2ns95zWpiQ=
-----END PRIVATE KEY-----
+29
View File
@@ -0,0 +1,29 @@
Name: chat-api
Host: 0.0.0.0
Port: 28888
Hybrid:
Name: chat-hybrid
Protocol: auto
Ws:
Name: chat-ws
Addr: :28889
Path: /ws/chat
MaxConnections: 10000
Auth:
Enabled: true
Source: envoy-header
HeaderName: X-User-ID
FallbackStrategy: auto
MaxRetries: 3
MaxConnections: 10000
Auth:
Enabled: true
WsHeaderName: X-User-ID
Stateless:
PollInterval: 100ms
BatchSize: 100
Log:
Level: debug
+37
View File
@@ -0,0 +1,37 @@
Name: chat-api
Host: 0.0.0.0
Port: 8888
Hybrid:
Name: chat-hybrid
Protocol: auto
Ws:
Name: chat-ws
Addr: :8889
Path: /ws/chat
MaxConnections: 10000
Auth:
Enabled: true
Source: envoy-header
HeaderName: X-User-ID
Wt:
Addr: :8443
Path: /wt/chat
CertFile: /etc/certs/tls.crt
KeyFile: /etc/certs/tls.key
FallbackStrategy: auto
MaxRetries: 3
MaxConnections: 10000
Auth:
Enabled: true
WsHeaderName: X-User-ID
WtTokenSource: query
WtTokenName: token
WtJWTSecret: test-secret
Stateless:
PollInterval: 100ms
BatchSize: 100
Log:
Level: debug
+17
View File
@@ -0,0 +1,17 @@
services:
chat-api:
build:
context: ../../../../
dockerfile: juwan-backend/app/chat/test/Dockerfile.api
container_name: chat-api-test
ports:
- "28888:8888"
- "28889:8889"
- "28443:8443/udp"
volumes:
- ./certs:/etc/certs:ro
healthcheck:
test: ["CMD", "true"]
interval: 2s
timeout: 2s
retries: 5
+5
View File
@@ -0,0 +1,5 @@
Name: pb.rpc
ListenOn: 0.0.0.0:28080
Log:
Level: debug
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_DIR="$SCRIPT_DIR/logs"
mkdir -p "$LOG_DIR"
echo "=== Chat Service Test Runner ==="
echo "Log directory: $LOG_DIR"
echo ""
cleanup() {
echo ""
echo "=== Collecting container logs ==="
docker compose -f "$SCRIPT_DIR/docker-compose.yml" logs chat-rpc > "$LOG_DIR/chat-rpc.log" 2>&1 || true
docker compose -f "$SCRIPT_DIR/docker-compose.yml" logs chat-api > "$LOG_DIR/chat-api.log" 2>&1 || true
echo "=== Stopping containers ==="
docker compose -f "$SCRIPT_DIR/docker-compose.yml" down --remove-orphans 2>/dev/null || true
}
trap cleanup EXIT
echo "=== Step 1: Building Docker images ==="
docker compose -f "$SCRIPT_DIR/docker-compose.yml" build 2>&1 | tee "$LOG_DIR/build.log"
echo ""
echo "=== Step 2: Starting services ==="
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d 2>&1 | tee -a "$LOG_DIR/build.log"
echo ""
echo "=== Step 3: Waiting for services to be ready ==="
for i in $(seq 1 30); do
if curl -s http://localhost:28888 > /dev/null 2>&1 || [ $i -eq 30 ]; then
break
fi
echo " waiting... ($i/30)"
sleep 2
done
sleep 3
echo "Services should be ready."
echo ""
echo "=== Step 4: Running WebSocket tests ==="
cd "$SCRIPT_DIR"
python3 test_ws.py 2>&1 | tee "$LOG_DIR/ws_test_stdout.log"
WS_RC=${PIPESTATUS[0]}
echo ""
echo "=== Step 5: Running WebTransport fallback tests ==="
python3 test_wt.py 2>&1 | tee "$LOG_DIR/wt_test_stdout.log"
WT_RC=${PIPESTATUS[0]}
echo ""
echo "=== Test Summary ==="
echo "WebSocket test: $([ $WS_RC -eq 0 ] && echo 'PASSED' || echo 'FAILED')"
echo "WebTransport test: $([ $WT_RC -eq 0 ] && echo 'PASSED' || echo 'FAILED')"
echo ""
echo "Logs saved to: $LOG_DIR/"
ls -la "$LOG_DIR/"
if [ $WS_RC -ne 0 ] || [ $WT_RC -ne 0 ]; then
exit 1
fi
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""WebSocket chat test — group chat + DM flows."""
import asyncio
import json
import sys
import time
try:
import websockets
except ImportError:
print("installing websockets...")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"])
import websockets
WS_URL = "ws://localhost:28889/ws/chat"
RESULTS = []
def log(tag, msg):
ts = time.strftime("%H:%M:%S")
line = f"[{ts}] [{tag}] {msg}"
print(line)
RESULTS.append(line)
async def recv_json(ws, timeout=5):
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
return json.loads(raw)
async def send_json(ws, data):
await ws.send(json.dumps(data))
async def test_ws():
log("TEST", "=== WebSocket Chat Test Start ===")
log("WS", "connecting user1...")
user1 = await websockets.connect(WS_URL, additional_headers={"X-User-ID": "1001"})
resp = await recv_json(user1)
log("WS", f"user1 connected: {resp}")
assert resp["type"] == "connected", f"expected connected, got {resp['type']}"
log("WS", "connecting user2...")
user2 = await websockets.connect(WS_URL, additional_headers={"X-User-ID": "1002"})
resp = await recv_json(user2)
log("WS", f"user2 connected: {resp}")
assert resp["type"] == "connected"
log("TEST", "--- Test 1: Create Group ---")
await send_json(user1, {"type": "create_group", "name": "test-room"})
resp = await recv_json(user1)
log("WS", f"create_group response: {resp}")
assert resp["type"] == "group_created", f"expected group_created, got {resp['type']}"
group_id = resp["sessionId"]
log("TEST", f"group created with id={group_id}")
log("TEST", "--- Test 2: Create DM ---")
await send_json(user1, {"type": "create_dm", "targetId": 1002})
resp = await recv_json(user1)
log("WS", f"create_dm response: {resp}")
assert resp["type"] == "dm_created", f"expected dm_created, got {resp['type']}"
dm_id = resp["sessionId"]
log("TEST", f"DM created with id={dm_id}")
log("TEST", "--- Test 3: Join Group ---")
await send_json(user1, {"type": "join", "sessionId": group_id})
msgs = []
for _ in range(2):
try:
r = await recv_json(user1, timeout=3)
msgs.append(r)
log("WS", f"join msg: {r}")
except asyncio.TimeoutError:
break
types = {m["type"] for m in msgs}
assert "joined" in types, f"expected 'joined' in {types}"
log("TEST", f"join received types: {types}")
log("TEST", "--- Test 4: Send Message in Group ---")
await send_json(user1, {"type": "message", "sessionId": group_id, "content": "hello group!"})
resp = await recv_json(user1)
log("WS", f"message broadcast: {resp}")
assert resp["type"] == "message", f"expected message, got {resp['type']}"
assert resp["content"] == "hello group!"
log("TEST", "--- Test 5: Send DM ---")
await send_json(user1, {"type": "message", "sessionId": dm_id, "content": "hello DM!"})
resp = await recv_json(user1)
log("WS", f"DM message: {resp}")
assert resp["type"] == "message"
assert resp["content"] == "hello DM!"
log("TEST", "--- Test 6: Message History ---")
await send_json(user1, {"type": "history", "sessionId": group_id})
resp = await recv_json(user1)
log("WS", f"history response: type={resp['type']} data_len={len(resp.get('data', []))}")
assert resp["type"] == "history"
log("TEST", "--- Test 7: Invalid Message ---")
await send_json(user1, {"type": "unknown_action"})
resp = await recv_json(user1)
log("WS", f"error response: {resp}")
assert resp["type"] == "error"
log("TEST", "--- Test 8: Leave Group ---")
await send_json(user1, {"type": "leave", "sessionId": group_id})
await user1.close()
await user2.close()
log("TEST", "=== WebSocket Chat Test PASSED ===")
async def main():
try:
await test_ws()
return 0
except Exception as e:
log("FAIL", f"Test failed: {e}")
import traceback
log("FAIL", traceback.format_exc())
return 1
if __name__ == "__main__":
rc = asyncio.run(main())
with open("logs/ws_test.log", "w") as f:
f.write("\n".join(RESULTS) + "\n")
sys.exit(rc)
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""WebTransport fallback test — verifies hybrid mode falls back to WS when WT is unavailable."""
import asyncio
import json
import sys
import time
import urllib.request
import urllib.error
try:
import websockets
except ImportError:
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"])
import websockets
WS_URL = "ws://localhost:28889/ws/chat"
API_BASE = "http://localhost:28888"
RESULTS = []
def log(tag, msg):
ts = time.strftime("%H:%M:%S")
line = f"[{ts}] [{tag}] {msg}"
print(line)
RESULTS.append(line)
async def recv_json(ws, timeout=5):
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
return json.loads(raw)
async def send_json(ws, data):
await ws.send(json.dumps(data))
async def test_wt_fallback():
log("TEST", "=== WebTransport Fallback Test Start ===")
log("WT", "--- Test 1: WT not configured, WS fallback should work ---")
log("WT", "connecting via WS (fallback path)...")
ws = await websockets.connect(WS_URL, additional_headers={"X-User-ID": "2001"})
resp = await recv_json(ws)
log("WT", f"fallback WS connected: {resp}")
assert resp["type"] == "connected", f"expected connected, got {resp['type']}"
log("WT", "--- Test 2: Full chat flow over fallback WS ---")
await send_json(ws, {"type": "create_group", "name": "wt-fallback-room"})
resp = await recv_json(ws)
log("WT", f"create_group via fallback: {resp}")
assert resp["type"] == "group_created"
group_id = resp["sessionId"]
await send_json(ws, {"type": "join", "sessionId": group_id})
resp1 = await recv_json(ws)
log("WT", f"join broadcast: {resp1}")
resp2 = await recv_json(ws)
log("WT", f"join confirm: {resp2}")
await send_json(ws, {"type": "message", "sessionId": group_id, "content": "hello from WT fallback!"})
resp = await recv_json(ws)
log("WT", f"message via fallback: {resp}")
assert resp["type"] == "message"
assert resp["content"] == "hello from WT fallback!"
log("WT", "--- Test 3: DM over fallback ---")
await send_json(ws, {"type": "create_dm", "targetId": 2002})
resp = await recv_json(ws)
log("WT", f"DM created via fallback: {resp}")
assert resp["type"] == "dm_created"
dm_id = resp["sessionId"]
await send_json(ws, {"type": "message", "sessionId": dm_id, "content": "DM via fallback"})
resp = await recv_json(ws)
log("WT", f"DM message via fallback: {resp}")
assert resp["type"] == "message"
log("WT", "--- Test 4: History over fallback ---")
await send_json(ws, {"type": "history", "sessionId": group_id})
resp = await recv_json(ws)
log("WT", f"history via fallback: type={resp['type']}")
assert resp["type"] == "history"
log("WT", "--- Test 5: Multi-user over fallback ---")
ws2 = await websockets.connect(WS_URL, additional_headers={"X-User-ID": "2002"})
resp = await recv_json(ws2)
assert resp["type"] == "connected"
log("WT", "user2 connected via fallback WS")
await send_json(ws, {"type": "message", "sessionId": dm_id, "content": "cross-user DM"})
resp = await recv_json(ws)
log("WT", f"sender got broadcast: {resp}")
assert resp["type"] == "message"
try:
resp2 = await recv_json(ws2, timeout=2)
log("WT", f"user2 got message: {resp2}")
except asyncio.TimeoutError:
log("WT", "user2 did not receive (not joined to session, expected)")
await ws.close()
await ws2.close()
log("WT", "--- Test 6: Verify WT port is not serving (no TLS configured) ---")
try:
wt_ws = await asyncio.wait_for(
websockets.connect("ws://localhost:28443/wt/chat"),
timeout=2
)
await wt_ws.close()
log("WT", "WT port unexpectedly open (might be OK if hybrid exposes it)")
except (ConnectionRefusedError, asyncio.TimeoutError, OSError):
log("WT", "WT port not available (expected — no TLS cert configured)")
log("TEST", "=== WebTransport Fallback Test PASSED ===")
async def main():
try:
await test_wt_fallback()
return 0
except Exception as e:
log("FAIL", f"Test failed: {e}")
import traceback
log("FAIL", traceback.format_exc())
return 1
if __name__ == "__main__":
rc = asyncio.run(main())
with open("logs/wt_test.log", "w") as f:
f.write("\n".join(RESULTS) + "\n")
sys.exit(rc)