Initial commit

This commit is contained in:
wangxuming
2026-07-12 15:53:24 +08:00
commit 68d61700d5
252 changed files with 23291 additions and 0 deletions

16
scripts/init-db.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
PG_BIN="${PG_BIN:-/opt/homebrew/opt/postgresql@17/bin}"
if ! "$PG_BIN/psql" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = 'queue'" | grep -q 1; then
"$PG_BIN/psql" -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE queue LOGIN PASSWORD 'queue'"
fi
if ! "$PG_BIN/psql" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'queue'" | grep -q 1; then
"$PG_BIN/createdb" -O queue queue
fi
"$PG_BIN/psql" "postgres://queue:queue@localhost:5432/queue?sslmode=disable" -v ON_ERROR_STOP=1 -c "SELECT 1" >/dev/null
echo "PostgreSQL development database is ready."

14
scripts/run-api.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR/server"
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
exec go run ./cmd/api

16
scripts/seed.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR/server"
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
: "${ADMIN_PASSWORD:=ChangeMe123!}"
export ADMIN_PASSWORD
exec go run ./cmd/seed

61
scripts/smoke-real.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PG_BIN="${PG_BIN:-/opt/homebrew/opt/postgresql@17/bin}"
SMOKE_PORT="${SMOKE_PORT:-18081}"
TEST_DB="queue_phone_smoke_$$"
API_PID=""
API_LOG="${TMPDIR:-/tmp}/${TEST_DB}.log"
API_BIN="${TMPDIR:-/tmp}/${TEST_DB}-api"
cleanup() {
if [[ -n "$API_PID" ]] && kill -0 "$API_PID" 2>/dev/null; then
kill -TERM "$API_PID" 2>/dev/null || true
wait "$API_PID" 2>/dev/null || true
fi
"$PG_BIN/dropdb" --if-exists --force "$TEST_DB" >/dev/null 2>&1 || true
rm -f "$API_LOG" "$API_BIN"
}
trap cleanup EXIT INT TERM
"$PG_BIN/createdb" -O queue "$TEST_DB"
set -a
source "$ROOT_DIR/server/.env"
set +a
export APP_ENV=development
export HTTP_ADDR=":$SMOKE_PORT"
export DATABASE_URL="postgres://queue:queue@localhost:5432/$TEST_DB?sslmode=disable"
export SESSION_COOKIE_NAME="queue_smoke_session_$$"
export SESSION_COOKIE_SECURE=false
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-ChangeMe123!}"
export ADMIN_USERNAME=smokeadmin
export SEED_MODE=smoke
export DEMO_DISPLAY_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
SEED_JSON="$(cd "$ROOT_DIR/server" && go run ./cmd/seed)"
DISPLAY_TOKEN="$(printf '%s' "$SEED_JSON" | python3 -c 'import json, sys; projects=json.load(sys.stdin).get("projects", []); print(projects[0]["display_url"].rstrip("/").rsplit("/", 1)[-1])')"
(cd "$ROOT_DIR/server" && go build -o "$API_BIN" ./cmd/api)
"$API_BIN" >"$API_LOG" 2>&1 &
API_PID=$!
READY=0
for _ in {1..50}; do
if curl -fsS "http://127.0.0.1:$SMOKE_PORT/readyz" >/dev/null 2>&1; then
READY=1
break
fi
sleep 0.1
done
if [[ "$READY" != "1" ]]; then
echo "Real smoke API did not become ready. Log follows:" >&2
sed -n '1,160p' "$API_LOG" >&2
exit 1
fi
python3 "$ROOT_DIR/scripts/smoke.py" \
--base-url "http://127.0.0.1:$SMOKE_PORT" \
--admin-username="$ADMIN_USERNAME" \
--display-token="$DISPLAY_TOKEN"

439
scripts/smoke.py Executable file
View File

@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""Run the first-slice contract against the real local Go API and PostgreSQL."""
from __future__ import annotations
import argparse
import http.cookiejar
import json
import time
import urllib.error
import urllib.request
import uuid
class Client:
def __init__(self, base_url: str) -> None:
self.base_url = base_url.rstrip("/")
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
)
def request(
self,
method: str,
path: str,
body: dict | None = None,
*,
key: str | None = None,
expected: int = 200,
) -> dict:
data = json.dumps(body).encode() if body is not None else None
headers = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if key:
headers["Idempotency-Key"] = key
request = urllib.request.Request(
self.base_url + path, data=data, headers=headers, method=method
)
try:
response = self.opener.open(request, timeout=10)
status = response.status
payload = json.loads(response.read() or b"{}")
except urllib.error.HTTPError as error:
status = error.code
payload = json.loads(error.read() or b"{}")
if status != expected:
raise AssertionError(
f"{method} {path}: expected {expected}, got {status}: {payload}"
)
return payload
def error_code(payload: dict) -> str | None:
error = payload.get("error")
return error.get("code") if isinstance(error, dict) else None
def mapping_keys(value: object) -> set[str]:
"""Collect every JSON object key recursively for privacy-boundary checks."""
if isinstance(value, dict):
keys = set(value)
for child in value.values():
keys.update(mapping_keys(child))
return keys
if isinstance(value, list):
keys: set[str] = set()
for child in value:
keys.update(mapping_keys(child))
return keys
return set()
def ticket_with_number(
tickets: object,
ticket_number: str,
source: str,
project_id: str | None = None,
) -> dict:
assert isinstance(tickets, list), f"{source} must be a ticket list"
matches = [
ticket
for ticket in tickets
if isinstance(ticket, dict)
and ticket.get("ticket_number") == ticket_number
and (project_id is None or ticket.get("project_id") == project_id)
]
assert len(matches) == 1, (
f"{source} must contain exactly one ticket {ticket_number}, got {len(matches)}"
)
return matches[0]
def assert_personal_continuity(
ticket: object,
*,
phone: str,
last_name: str,
honorific: str,
source: str,
) -> dict:
assert isinstance(ticket, dict), f"{source} must be a ticket object"
assert ticket.get("phone") == phone, f"{source} changed the ticket phone"
assert ticket.get("last_name") == last_name, (
f"{source} changed the ticket last name"
)
assert ticket.get("honorific") == honorific, (
f"{source} changed the ticket honorific"
)
return ticket
def assert_public_phone_projection(payload: dict, phone: str, source: str) -> None:
assert payload.get("phone_last4") == phone[-4:]
phone_keys = {
key for key in mapping_keys(payload) if "phone" in key.lower()
}
assert phone_keys == {"phone_last4"}, (
f"{source} may expose only phone_last4, got {phone_keys}"
)
assert phone not in json.dumps(payload, ensure_ascii=False), (
f"{source} leaked the complete phone"
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
parser.add_argument("--username", default="staff")
parser.add_argument("--admin-username", default="admin")
parser.add_argument("--password", default="ChangeMe123!")
parser.add_argument("--display-token", required=True)
args = parser.parse_args()
client = Client(args.base_url)
health = client.request("GET", "/healthz")
assert health["status"] == "ok"
client.request(
"POST",
"/api/staff/auth/login",
{"username": args.username, "password": args.password},
)
me = client.request("GET", "/api/staff/auth/me")
project_id = me["projects"][0]["id"]
snapshot = client.request("GET", f"/api/staff/projects/{project_id}/queue")
assert snapshot.get("current_batch") is None, (
"smoke requires a project with no active call batch; finish or miss the "
"current batch before running it"
)
assert snapshot.get("waiting") == [], (
"smoke requires a freshly rebuilt, dedicated database with no waiting "
"tickets; rebuild and seed the smoke database before running it"
)
phone = "139" + str(int(time.time() * 1000))[-8:]
last_name = "烟测"
honorific = "游客"
ticket_body = {
"phone": phone,
"last_name": last_name,
"honorific": honorific,
"allow_duplicate": False,
}
first_key = str(uuid.uuid4())
first = client.request(
"POST",
f"/api/staff/projects/{project_id}/tickets",
ticket_body,
key=first_key,
expected=201,
)
first_ticket = first["ticket"]
assert first_ticket["ticket_number"]
assert first_ticket["public_token"]
assert_personal_continuity(
first_ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="create response",
)
replay = client.request(
"POST",
f"/api/staff/projects/{project_id}/tickets",
ticket_body,
key=first_key,
expected=201,
)
assert replay["ticket"]["id"] == first_ticket["id"]
assert_personal_continuity(
replay["ticket"],
phone=phone,
last_name=last_name,
honorific=honorific,
source="create idempotency replay",
)
# Staff and admin are authenticated operational surfaces, so they must
# expose the complete phone beside the exact ticket number.
staff_snapshot = client.request(
"GET", f"/api/staff/projects/{project_id}/queue"
)
staff_ticket = ticket_with_number(
staff_snapshot["waiting"], first_ticket["ticket_number"], "staff queue"
)
assert_personal_continuity(
staff_ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="staff waiting queue",
)
public = client.request(
"GET", f"/api/public/status/{first_ticket['public_token']}"
)
assert public["ticket_number"] == first_ticket["ticket_number"]
assert_public_phone_projection(public, phone, "public status")
client.request(
"POST",
"/api/admin/auth/login",
{"username": args.admin_username, "password": args.password},
)
overview = client.request("GET", "/api/admin/overview")
admin_ticket = ticket_with_number(
overview.get("active_tickets"),
first_ticket["ticket_number"],
"admin active_tickets",
project_id,
)
assert_personal_continuity(
admin_ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="admin active_tickets",
)
duplicate = client.request(
"POST",
f"/api/staff/projects/{project_id}/tickets",
ticket_body,
key=str(uuid.uuid4()),
expected=409,
)
assert error_code(duplicate) == "DUPLICATE_PHONE"
confirmed_body = {**ticket_body, "allow_duplicate": True}
confirmed = client.request(
"POST",
f"/api/staff/projects/{project_id}/tickets",
confirmed_body,
key=str(uuid.uuid4()),
expected=201,
)
confirmed_ticket = confirmed["ticket"]
assert confirmed_ticket["id"] != first_ticket["id"]
assert_personal_continuity(
confirmed_ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="confirmed duplicate create response",
)
snapshot = client.request("GET", f"/api/staff/projects/{project_id}/queue")
project = snapshot["project"]
batch_size = int(
project.get("call_batch_size") or project.get("batch_size") or 0
)
assert batch_size > 0, "project call batch size must be positive"
created_tickets = [first_ticket, confirmed_ticket]
while len(created_tickets) < batch_size + 1:
extra = client.request(
"POST",
f"/api/staff/projects/{project_id}/tickets",
confirmed_body,
key=str(uuid.uuid4()),
expected=201,
)
created_tickets.append(extra["ticket"])
snapshot = client.request("GET", f"/api/staff/projects/{project_id}/queue")
waiting_before_call = snapshot["waiting"]
waiting_ids = {ticket["id"] for ticket in waiting_before_call}
assert waiting_ids == {ticket["id"] for ticket in created_tickets}, (
"dedicated smoke queue contains unexpected tickets"
)
for ticket in waiting_before_call:
assert_personal_continuity(
ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="staff waiting queue before call-next",
)
stale_call = client.request(
"POST",
f"/api/staff/projects/{project_id}/call-next",
{"expected_revision": max(0, int(snapshot["revision"]) - 1), "count": batch_size},
key=str(uuid.uuid4()),
expected=409,
)
assert error_code(stale_call) == "REVISION_CONFLICT"
expected_called_ids = [
ticket["id"] for ticket in waiting_before_call[:batch_size]
]
call_key = str(uuid.uuid4())
call_body = {"expected_revision": int(snapshot["revision"]), "count": batch_size}
called = client.request(
"POST",
f"/api/staff/projects/{project_id}/call-next",
call_body,
key=call_key,
)
called_tickets = called["batch"]["tickets"]
assert [ticket["id"] for ticket in called_tickets] == expected_called_ids
assert len(called_tickets) == min(batch_size, len(waiting_before_call))
assert called_tickets, "call-next must call at least one waiting ticket"
for ticket in called_tickets:
assert_personal_continuity(
ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="call-next response",
)
assert called["device_results"][0]["status"] == "SUCCESS"
call_replay = client.request(
"POST",
f"/api/staff/projects/{project_id}/call-next",
call_body,
key=call_key,
)
assert call_replay["batch"]["id"] == called["batch"]["id"]
for ticket in call_replay["batch"]["tickets"]:
assert_personal_continuity(
ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="call-next idempotency replay",
)
active_snapshot = client.request(
"GET", f"/api/staff/projects/{project_id}/queue"
)
current_batch = active_snapshot.get("current_batch")
assert isinstance(current_batch, dict)
assert current_batch["id"] == called["batch"]["id"]
assert [ticket["id"] for ticket in current_batch["tickets"]] == (
expected_called_ids
)
for ticket in current_batch["tickets"]:
assert_personal_continuity(
ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="staff current_batch",
)
second_called = client.request(
"POST",
f"/api/staff/projects/{project_id}/call-next",
{"expected_revision": int(called["revision"]), "count": 1},
key=str(uuid.uuid4()),
)
assert second_called["batch"]["id"] != called["batch"]["id"]
assert len(second_called["batch"]["tickets"]) == 1
final_staff_snapshot = client.request(
"GET", f"/api/staff/projects/{project_id}/queue"
)
assert final_staff_snapshot["current_batch"]["id"] == second_called["batch"]["id"]
assert final_staff_snapshot["metrics"]["waiting_count"] == 0
completed_public = client.request(
"GET", f"/api/public/status/{first_ticket['public_token']}"
)
assert completed_public["status"] == "COMPLETED"
assert_public_phone_projection(completed_public, phone, "auto-completed public status")
display = client.request(
"GET", f"/api/display/{args.display_token}/snapshot"
)
serialized_display = json.dumps(display, ensure_ascii=False)
display_keys = mapping_keys(display)
assert "honorific" not in display_keys
display_phone_keys = {
key for key in display_keys if "phone" in key.lower()
}
forbidden_display_phone_keys = {"phone", "phone_last4", "phone_masked"}
assert display_phone_keys.isdisjoint(forbidden_display_phone_keys)
assert not display_phone_keys, (
f"display leaked phone fields: {sorted(display_phone_keys)}"
)
assert phone not in serialized_display
current_numbers = {
ticket["ticket_number"]
for ticket in (display.get("current_batch") or {}).get("tickets", [])
}
assert first_ticket["ticket_number"] not in current_numbers
overview = client.request("GET", "/api/admin/overview")
project = next(item for item in overview["projects"] if item["id"] == project_id)
assert project["waiting_count"] == 0
current_admin_ticket = ticket_with_number(
overview.get("active_tickets"),
second_called["batch"]["tickets"][0]["ticket_number"],
"admin active_tickets after automatic rotation",
project_id,
)
assert_personal_continuity(
current_admin_ticket,
phone=phone,
last_name=last_name,
honorific=honorific,
source="admin active_tickets after automatic rotation",
)
print(
json.dumps(
{
"status": "ok",
"project_id": project_id,
"first_ticket": first_ticket["ticket_number"],
"first_batch_id": called["batch"]["id"],
"second_batch_id": second_called["batch"]["id"],
},
ensure_ascii=False,
indent=2,
)
)
if __name__ == "__main__":
main()

27
scripts/test-postgres.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PG_BIN="${PG_BIN:-/opt/homebrew/opt/postgresql@17/bin}"
TEST_DB="queue_schema_test_$$"
cleanup() {
"$PG_BIN/dropdb" --if-exists --force "$TEST_DB" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
"$PG_BIN/createdb" -O queue "$TEST_DB"
(
export TEST_DATABASE_URL="postgres://queue:queue@localhost:5432/$TEST_DB?sslmode=disable"
cd "$ROOT_DIR/server"
go test ./internal/database -run TestPostgresMigrationAndMaintenanceIntegration -count=1
)
set -a
source "$ROOT_DIR/server/.env"
set +a
export APP_ENV=development
export DATABASE_URL="postgres://queue:queue@localhost:5432/$TEST_DB?sslmode=disable"
export MIGRATE_ON_START=false
export SUPER_ADMIN_PASSWORD='IntegrationOnlyPassword123!'
(cd "$ROOT_DIR/server" && go run ./cmd/bootstrap-admin >/dev/null)