diff --git a/admin-web/package.json b/admin-web/package.json index d4873b7..ee4ed69 100644 --- a/admin-web/package.json +++ b/admin-web/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vue-tsc -b && npm run build" }, "dependencies": { "axios": "^1.18.1", diff --git a/backend/mci-server/Dockerfile b/backend/mci-server/Dockerfile index 0a303df..087699b 100644 --- a/backend/mci-server/Dockerfile +++ b/backend/mci-server/Dockerfile @@ -14,6 +14,8 @@ RUN mvn -N install && \ FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl + WORKDIR /app COPY --from=builder /app/mci-server/target/*.jar app.jar diff --git a/channel-service/Dockerfile b/channel-service/Dockerfile index 2bf6443..78302d6 100644 --- a/channel-service/Dockerfile +++ b/channel-service/Dockerfile @@ -2,6 +2,8 @@ FROM python:3.11-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/docker-compose.app.yml b/docker-compose.app.yml deleted file mode 100644 index 32347ac..0000000 --- a/docker-compose.app.yml +++ /dev/null @@ -1,45 +0,0 @@ -version: '3.8' - -services: - mci-server: - build: - context: ./backend - dockerfile: mci-server/Dockerfile - container_name: mci-server - ports: - - "8080:8080" - environment: - - SPRING_PROFILES_ACTIVE=dev - - SPRING_DATASOURCE_URL=jdbc:mysql://mci-mysql:3306/sino_mci?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true - - SPRING_DATASOURCE_USERNAME=root - - SPRING_DATASOURCE_PASSWORD=root - - SPRING_DATA_REDIS_HOST=mci-redis - - SPRING_RABBITMQ_HOST=mci-rabbitmq - networks: - - mci-network - - admin-web: - build: - context: ./admin-web - dockerfile: Dockerfile - container_name: mci-admin-web - ports: - - "80:80" - networks: - - mci-network - - channel-service: - build: - context: ./channel-service - dockerfile: Dockerfile - container_name: mci-channel-service - ports: - - "8000:8000" - environment: - - MCI_SERVER_URL=http://mci-server:8080/msg-platform - networks: - - mci-network - -networks: - mci-network: - external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..09057ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,144 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0.36 + container_name: mci-mysql + profiles: ["infra"] + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: sino_mci + ports: + - "3306:3306" + volumes: + - mci-mysql-data:/var/lib/mysql + networks: + - mci-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + + redis: + image: redis:7.2 + container_name: mci-redis + profiles: ["infra"] + ports: + - "6379:6379" + networks: + - mci-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + rabbitmq: + image: rabbitmq:3.13-management + container_name: mci-rabbitmq + profiles: ["infra"] + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - "5672:5672" + - "15672:15672" + networks: + - mci-network + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 5s + timeout: 5s + retries: 15 + start_period: 30s + + seaweedfs: + image: chrislusf/seaweedfs:3.62 + container_name: mci-seaweedfs + profiles: ["infra"] + ports: + - "9333:9333" + - "8888:8888" + command: server -dir=/data + volumes: + - mci-seaweedfs-data:/data + networks: + - mci-network + + mci-server: + build: + context: ./backend + dockerfile: mci-server/Dockerfile + container_name: mci-server + profiles: ["app"] + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=dev + - SPRING_DATASOURCE_URL=jdbc:mysql://mci-mysql:3306/sino_mci?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + - SPRING_DATASOURCE_USERNAME=root + - SPRING_DATASOURCE_PASSWORD=root + - SPRING_DATA_REDIS_HOST=mci-redis + - SPRING_RABBITMQ_HOST=mci-rabbitmq + networks: + - mci-network + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/msg-platform/actuator/health"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 90s + + admin-web: + build: + context: ./admin-web + dockerfile: Dockerfile + container_name: mci-admin-web + profiles: ["app"] + ports: + - "80:80" + networks: + - mci-network + depends_on: + mci-server: + condition: service_healthy + + channel-service: + build: + context: ./channel-service + dockerfile: Dockerfile + container_name: mci-channel-service + profiles: ["app"] + ports: + - "8000:8000" + environment: + - MCI_SERVER_URL=http://mci-server:8080/msg-platform + networks: + - mci-network + depends_on: + mci-server: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + mci-mysql-data: + mci-seaweedfs-data: + +networks: + mci-network: + driver: bridge diff --git a/docs/superpowers/specs/ui-send-record-success.png b/docs/superpowers/specs/ui-send-record-success.png new file mode 100644 index 0000000..f7ed08c Binary files /dev/null and b/docs/superpowers/specs/ui-send-record-success.png differ diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml deleted file mode 100644 index 20077f7..0000000 --- a/infrastructure/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -version: '3.8' - -services: - mysql: - image: mysql:8.0.36 - container_name: mci-mysql - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: sino_mci - ports: - - "3306:3306" - volumes: - - mci-mysql-data:/var/lib/mysql - networks: - - mci-network - - redis: - image: redis:7.2 - container_name: mci-redis - ports: - - "6379:6379" - networks: - - mci-network - - rabbitmq: - image: rabbitmq:3.13-management - container_name: mci-rabbitmq - environment: - RABBITMQ_DEFAULT_USER: guest - RABBITMQ_DEFAULT_PASS: guest - ports: - - "5672:5672" - - "15672:15672" - networks: - - mci-network - - seaweedfs: - image: chrislusf/seaweedfs:3.62 - container_name: mci-seaweedfs - ports: - - "9333:9333" - - "8888:8888" - command: server -dir=/data - volumes: - - mci-seaweedfs-data:/data - networks: - - mci-network - -volumes: - mci-mysql-data: - mci-seaweedfs-data: - -networks: - mci-network: - external: true diff --git a/scripts/local-up.sh b/scripts/local-up.sh index faa46c4..08118e0 100755 --- a/scripts/local-up.sh +++ b/scripts/local-up.sh @@ -4,47 +4,42 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR/.." -echo "Creating shared network..." -docker network create mci-network 2>/dev/null || true +wait_for_healthy() { + local service=$1 + local max_retries=${2:-60} + local retry_count=0 + local container_name -echo "Starting local infrastructure..." -docker compose -f infrastructure/docker-compose.yml up -d + echo "Waiting for $service to become healthy..." + while [ "$retry_count" -lt "$max_retries" ]; do + container_name=$(docker compose --profile infra --profile app ps -q "$service" 2>/dev/null | head -n1) -echo "Waiting for MySQL..." -MAX_RETRIES=60 -RETRY_COUNT=0 -until docker exec mci-mysql mysql -uroot -proot -e "SELECT 1" > /dev/null 2>&1; do - RETRY_COUNT=$((RETRY_COUNT + 1)) - if [ "$RETRY_COUNT" -ge "$MAX_RETRIES" ]; then - echo "MySQL did not become ready within ${MAX_RETRIES} seconds." - exit 1 - fi - sleep 1 -done + if [ -n "$container_name" ]; then + local status + status=$(docker inspect --format='{{.State.Health.Status}}' "$container_name" 2>/dev/null || true) + if [ "$status" = "healthy" ]; then + echo "$service is healthy." + return 0 + fi + fi -echo "Waiting for Redis..." -RETRY_COUNT=0 -until docker exec mci-redis redis-cli ping | grep -q "PONG"; do - RETRY_COUNT=$((RETRY_COUNT + 1)) - if [ "$RETRY_COUNT" -ge 30 ]; then - echo "Redis did not become ready within 30 seconds." - exit 1 - fi - sleep 1 -done + retry_count=$((retry_count + 1)) + sleep 2 + done -echo "Waiting for RabbitMQ..." -RETRY_COUNT=0 -until curl -s -o /dev/null -w "%{http_code}" http://guest:guest@localhost:15672/api/overview | grep -q "200"; do - RETRY_COUNT=$((RETRY_COUNT + 1)) - if [ "$RETRY_COUNT" -ge 30 ]; then - echo "RabbitMQ did not become ready within 30 seconds." - exit 1 - fi - sleep 1 -done + echo "$service did not become healthy within $((max_retries * 2)) seconds." + docker compose --profile infra --profile app logs --tail=50 "$service" + return 1 +} -echo "Local infrastructure is ready." +echo "Starting infrastructure services..." +docker compose --profile infra up -d + +wait_for_healthy mysql 60 +wait_for_healthy redis 30 +wait_for_healthy rabbitmq 60 + +echo "Infrastructure is ready." echo "MySQL: jdbc:mysql://localhost:3306/sino_mci" echo "Redis: localhost:6379" echo "RabbitMQ: localhost:5672 / http://localhost:15672" @@ -52,7 +47,13 @@ echo "SeaweedFS: http://localhost:9333" if [ "$1" == "--with-apps" ]; then echo "Starting application services..." - docker compose -f docker-compose.app.yml up -d --build + # Include infra profile so that depends_on conditions can resolve. + docker compose --profile infra --profile app up -d --build + + wait_for_healthy mci-server 90 + wait_for_healthy channel-service 60 + + echo "Application services are ready." echo "Admin Web: http://localhost" echo "Backend API: http://localhost:8080/msg-platform" echo "Channel Service: http://localhost:8000" diff --git a/scripts/test-external-api.sh b/scripts/test-external-api.sh new file mode 100755 index 0000000..38e1793 --- /dev/null +++ b/scripts/test-external-api.sh @@ -0,0 +1,159 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +BASE_URL="${BASE_URL:-http://localhost/msg-platform}" +CHANNEL_URL="${CHANNEL_URL:-http://localhost:8000}" + +command -v curl >/dev/null 2>&1 || { echo "curl is required but not installed."; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "jq is required but not installed."; exit 1; } + +PASS_COUNT=0 +FAIL_COUNT=0 + +TENANT_CODE="ext-test-$(date +%s)" + +make_request() { + local method=$1 + local url=$2 + local payload=$3 + shift 3 + local headers=("$@") + + if [ "$method" = "GET" ]; then + curl -s -S -X GET "${headers[@]}" "$url" + elif [ -n "$payload" ]; then + curl -s -S -X "$method" "${headers[@]}" -H "Content-Type: application/json" -d "$payload" "$url" + else + curl -s -S -X "$method" "${headers[@]}" -H "Content-Type: application/json" "$url" + fi +} + +assert_code_zero() { + local name=$1 + local response=$2 + local code + code=$(echo "$response" | jq -r '.code // .errcode // empty') + + if [ "$code" = "0" ]; then + echo "[PASS] $name" + PASS_COUNT=$((PASS_COUNT + 1)) + return 0 + else + echo "[FAIL] $name (code=$code)" + echo " response: $response" + FAIL_COUNT=$((FAIL_COUNT + 1)) + return 1 + fi +} + +# 1. Create tenant +echo "==> 1. Create tenant" +TENANT_RESPONSE=$(make_request POST "$BASE_URL/api/v1/account/tenant" \ + '{"tenantCode":"'"$TENANT_CODE"'","tenantName":"External API Test"}') +assert_code_zero "Create tenant" "$TENANT_RESPONSE" + +# 2. Create channel subject +echo "==> 2. Create channel subject" +SUBJECT_RESPONSE=$(make_request POST "$BASE_URL/api/v1/channel-subject" \ + '{"channelType":"wecom","subjectCode":"sino","subjectName":"Sino Test"}' \ + -H "X-Tenant-Code: $TENANT_CODE") +assert_code_zero "Create channel subject" "$SUBJECT_RESPONSE" + +# 3. Create channel account +echo "==> 3. Create channel account" +ACCOUNT_RESPONSE=$(make_request POST "$BASE_URL/api/v1/channel-account" \ + '{"accountType":1,"channelType":"wecom","accountId":"ZhangSan","accountName":"张三"}' \ + -H "X-Tenant-Code: $TENANT_CODE") +assert_code_zero "Create channel account" "$ACCOUNT_RESPONSE" +ACCOUNT_ID=$(echo "$ACCOUNT_RESPONSE" | jq -r '.data.id') + +# 4. Create conversation +echo "==> 4. Create conversation" +CONVERSATION_RESPONSE=$(make_request POST "$BASE_URL/api/v1/conversation" \ + '{"conversationType":2,"channelType":"wecom","channelConversationId":"room-test-'"$(date +%s)"'","conversationName":"Test Group"}' \ + -H "X-Tenant-Code: $TENANT_CODE") +assert_code_zero "Create conversation" "$CONVERSATION_RESPONSE" +CONVERSATION_ID=$(echo "$CONVERSATION_RESPONSE" | jq -r '.data.id') + +# 5. Bind account to conversation +echo "==> 5. Bind account to conversation" +BIND_RESPONSE=$(make_request POST "$BASE_URL/api/v1/conversation/$CONVERSATION_ID/bind-account" \ + "{\"accountId\":$ACCOUNT_ID,\"bindingType\":1,\"sortOrder\":1}" \ + -H "X-Tenant-Code: $TENANT_CODE") +assert_code_zero "Bind account to conversation" "$BIND_RESPONSE" + +# 6. Test send +echo "==> 6. Test send" +SEND_RESPONSE=$(make_request POST "$BASE_URL/api/v1/send/test" \ + "{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":$CONVERSATION_ID},\"contentType\":\"text\",\"content\":{\"text\":\"hello from external api test\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}" \ + -H "X-Tenant-Code: $TENANT_CODE") +assert_code_zero "Test send" "$SEND_RESPONSE" +TASK_ID=$(echo "$SEND_RESPONSE" | jq -r '.data.taskId') + +# 7. Query send records +echo "==> 7. Query send records" +RECORDS_RESPONSE=$(make_request GET "$BASE_URL/api/v1/send/$TASK_ID/records" "" \ + -H "X-Tenant-Code: $TENANT_CODE") +assert_code_zero "Query send records" "$RECORDS_RESPONSE" + +# 8. Receive inbound message +echo "==> 8. Receive inbound message" +EVENT_ID="evt-$(date +%s)-$RANDOM" +INBOUND_RESPONSE=$(make_request POST "$BASE_URL/api/v1/inbound/receive" \ + "{\"eventId\":\"$EVENT_ID\",\"channelType\":\"wecom\",\"channelSubjectId\":1,\"eventType\":\"message\",\"messageSource\":\"realtime\",\"conversationId\":$CONVERSATION_ID,\"accountId\":$ACCOUNT_ID,\"content\":{\"text\":\"hello inbound\"},\"rawPayload\":{},\"businessContext\":{}}" \ + -H "X-Tenant-Code: $TENANT_CODE") +assert_code_zero "Receive inbound message" "$INBOUND_RESPONSE" + +# 9. Channel-service health +echo "==> 9. Channel-service health" +HEALTH_RESPONSE=$(curl -s -S "$CHANNEL_URL/health") +if echo "$HEALTH_RESPONSE" | jq -e '.status == "ok"' >/dev/null 2>&1; then + echo "[PASS] Channel-service health" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "[FAIL] Channel-service health" + echo " response: $HEALTH_RESPONSE" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi + +# 10. WeChat personal login start +echo "==> 10. WeChat personal login start" +LOGIN_RESPONSE=$(make_request POST "$CHANNEL_URL/v1/wechat-personal/login/start" \ + '{"account_id":"bot-test-'"$(date +%s)"'","account_name":"Test Bot"}') +if echo "$LOGIN_RESPONSE" | jq -e '.login_id' >/dev/null 2>&1; then + echo "[PASS] WeChat personal login start" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "[FAIL] WeChat personal login start" + echo " response: $LOGIN_RESPONSE" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi + +# 11. WeChat personal send text (after confirming a login) +echo "==> 11. WeChat personal send text" +BOT_ID="bot-send-$(date +%s)" +make_request POST "$CHANNEL_URL/v1/wechat-personal/login/start" \ + '{"account_id":"'"$BOT_ID"'"}' >/dev/null +make_request POST "$CHANNEL_URL/v1/wechat-personal/login/$BOT_ID/confirm" "{}" >/dev/null +SEND_TEXT_RESPONSE=$(make_request POST "$CHANNEL_URL/v1/wechat-personal/send/text" \ + '{"account_id":"'"$BOT_ID"'","conversation_id":"room-test","text":"hello"}') +if echo "$SEND_TEXT_RESPONSE" | jq -e '.success == true' >/dev/null 2>&1; then + echo "[PASS] WeChat personal send text" + PASS_COUNT=$((PASS_COUNT + 1)) +else + echo "[FAIL] WeChat personal send text" + echo " response: $SEND_TEXT_RESPONSE" + FAIL_COUNT=$((FAIL_COUNT + 1)) +fi + +echo "" +echo "=========================" +echo "Total: $((PASS_COUNT + FAIL_COUNT)), Passed: $PASS_COUNT, Failed: $FAIL_COUNT" +echo "=========================" + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi