diff --git a/VM1_app/codebase/backend/api-backend/app/services.py b/VM1_app/codebase/backend/api-backend/app/services.py index ae55237..c929a38 100644 --- a/VM1_app/codebase/backend/api-backend/app/services.py +++ b/VM1_app/codebase/backend/api-backend/app/services.py @@ -117,6 +117,67 @@ async def ensure_delivery_outbox( return outbox +OPENLINES_FALLBACK_DISPLAY_NAME = "Новый клиент HAN" + + +def openlines_display_name(full_name: str | None) -> str: + name = (full_name or "").strip() + return name or OPENLINES_FALLBACK_DISPLAY_NAME + + +def openlines_user_payload(user: UserIdentity, profile: ClientProfile | None) -> dict[str, str]: + return { + "id": str(user.id), + "display_name": openlines_display_name(profile.full_name if profile else None), + "phone": user.phone_number, + } + + +async def load_active_client_profile( + session: AsyncSession, user_id: uuid.UUID +) -> ClientProfile | None: + return ( + await session.execute( + select(ClientProfile).where( + ClientProfile.user_id == user_id, ClientProfile.record_status == "A" + ) + ) + ).scalar_one_or_none() + + +def openlines_delivery_payload( + *, + message: Message, + dialog_id: uuid.UUID, + user: UserIdentity, + profile: ClientProfile | None, + attachment: MessageAttachment | None, +) -> dict[str, Any]: + files: list[dict[str, Any]] = [] + if attachment: + files.append( + { + "attachment_id": str(attachment.id), + "name": attachment.safe_file_name, + "mime_type": attachment.mime_type, + "size_bytes": attachment.size_bytes, + "_storage_bucket": attachment.storage_bucket, + "_object_key": attachment.object_key, + } + ) + return { + "message_id": str(message.id), + "external_chat_id": str(dialog_id), + "occurred_at": message.occurred_at.isoformat(), + "user": openlines_user_payload(user, profile), + "message": { + "content_kind": message.content_kind, + "text": message.text, + "files": files, + }, + } + + class DomainError(Exception): def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None): self.code, self.status, self.message = code, status, message @@ -1032,34 +1093,18 @@ async def send_message( "bypassed" if verdict["processing_mode"] == "mock" else "clean" ) message.safety_status = "allowed" + profile = await load_active_client_profile(session, user.id) outbox = await ensure_delivery_outbox( session, message_id=message.id, external_chat_id=dialog_id, - payload_json={ - "message_id": str(message.id), - "external_chat_id": str(dialog_id), - "occurred_at": message.occurred_at.isoformat(), - "user": {"id": str(user.id), "display_name": user.phone_number}, - "message": { - "content_kind": message.content_kind, - "text": message.text, - "files": ( - [ - { - "attachment_id": str(attachment.id), - "name": attachment.safe_file_name, - "mime_type": attachment.mime_type, - "size_bytes": attachment.size_bytes, - "_storage_bucket": attachment.storage_bucket, - "_object_key": attachment.object_key, - } - ] - if attachment - else [] - ), - }, - }, + payload_json=openlines_delivery_payload( + message=message, + dialog_id=dialog_id, + user=user, + profile=profile, + attachment=attachment, + ), # Keep the row recoverable after a process crash, but do not let the # delivery worker race the synchronous first attempt. next_attempt_at=datetime.now(UTC) diff --git a/VM1_app/codebase/backend/api-backend/app/workers.py b/VM1_app/codebase/backend/api-backend/app/workers.py index 8f7cf3b..1063e18 100644 --- a/VM1_app/codebase/backend/api-backend/app/workers.py +++ b/VM1_app/codebase/backend/api-backend/app/workers.py @@ -30,7 +30,9 @@ from app.notification_service import expire_notifications from app.realtime import RealtimeFanout from app.services import ( ensure_delivery_outbox, + load_active_client_profile, load_settings, + openlines_delivery_payload, publish_dialog_status, publish_message_status, ) @@ -189,37 +191,18 @@ async def safety_once( else None ) if dialog and user: + profile = await load_active_client_profile(session, user.id) await ensure_delivery_outbox( session, message_id=message.id, external_chat_id=dialog.id, - payload_json={ - "message_id": str(message.id), - "external_chat_id": str(dialog.id), - "occurred_at": message.occurred_at.isoformat(), - "user": { - "id": str(user.id), - "display_name": user.phone_number, - }, - "message": { - "content_kind": message.content_kind, - "text": message.text, - "files": ( - [ - { - "attachment_id": str(attachment.id), - "name": attachment.safe_file_name, - "mime_type": attachment.mime_type, - "size_bytes": attachment.size_bytes, - "_storage_bucket": attachment.storage_bucket, - "_object_key": attachment.object_key, - } - ] - if attachment - else [] - ), - }, - }, + payload_json=openlines_delivery_payload( + message=message, + dialog_id=dialog.id, + user=user, + profile=profile, + attachment=attachment, + ), next_attempt_at=datetime.now(UTC), ) elif verdict["_status"] == 403 and message: diff --git a/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py b/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py index 4213ba5..f2262c2 100644 --- a/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py +++ b/VM1_app/codebase/backend/api-backend/tests/contract/test_clients.py @@ -304,7 +304,11 @@ async def test_openlines_payload_gets_fresh_download_url_without_storage_fields( "message_id": str(uuid.uuid4()), "external_chat_id": str(uuid.uuid4()), "occurred_at": "2026-07-10T12:00:00+00:00", - "user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"}, + "user": { + "id": str(uuid.uuid4()), + "display_name": "Новый клиент HAN", + "phone": "+79990000000", + }, "message": { "content_kind": "file", "text": "", diff --git a/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py b/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py index cc9ec4e..7a39cab 100644 --- a/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py +++ b/VM1_app/codebase/backend/api-backend/tests/unit/test_domain.py @@ -20,6 +20,7 @@ from app.schemas import ( from app.services import ( MESSAGE_SAFETY_REPLIES, ensure_delivery_outbox, + openlines_user_payload, safety_reply_message, safety_task_recovery_at, ) @@ -85,6 +86,20 @@ def test_safety_recovery_starts_after_synchronous_polling_window() -> None: assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307 +def test_openlines_user_payload_uses_profile_name_and_phone() -> None: + user = SimpleNamespace(id=uuid.uuid4(), phone_number="+79991234567") + named = openlines_user_payload(user, SimpleNamespace(full_name="Иван Иванов")) + assert named == { + "id": str(user.id), + "display_name": "Иван Иванов", + "phone": "+79991234567", + } + assert openlines_user_payload(user, SimpleNamespace(full_name=" "))["display_name"] == ( + "Новый клиент HAN" + ) + assert openlines_user_payload(user, None)["display_name"] == "Новый клиент HAN" + + async def test_delivery_outbox_returns_concurrent_insert_winner() -> None: existing = object() session = SimpleNamespace( diff --git a/VM1_app/codebase/backend/bitrix-local-app/app/main.py b/VM1_app/codebase/backend/bitrix-local-app/app/main.py index 1121720..dca6b43 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/app/main.py +++ b/VM1_app/codebase/backend/bitrix-local-app/app/main.py @@ -114,6 +114,7 @@ class UserDto(BaseModel): model_config = ConfigDict(extra="forbid") id: uuid.UUID display_name: str = Field(min_length=1, max_length=255) + phone: str | None = Field(default=None, min_length=1, max_length=32) class FileDto(BaseModel): @@ -697,7 +698,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: "Idempotency-Key must equal message_id", ), ) - body = dto.model_dump(mode="json") + body = dto.model_dump(mode="json", exclude_none=True) fp = canonical_fingerprint(body) async with request.app.state.sessions() as session: row = await session.scalar( @@ -992,17 +993,11 @@ def extract_delivery(result: dict[str, Any]) -> tuple[int | None, str | None, st ) -async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]: - async with app.state.sessions() as session: - row = await session.get(OutboundMessage, row_id) - portal = await active_portal(session) - if not row or not portal: - raise RuntimeError("portal_not_installed") - payload = row.payload_json +def imconnector_send_fields(connector: str, line: str, payload: dict[str, Any]) -> dict[str, Any]: message = payload["message"] fields: dict[str, Any] = { - "CONNECTOR": app.state.settings.bitrix_connector_id, - "LINE": app.state.settings.bitrix_open_line_id, + "CONNECTOR": connector, + "LINE": line, "MESSAGES[0][user][id]": payload["user"]["id"], "MESSAGES[0][user][name]": payload["user"]["display_name"], "MESSAGES[0][message][id]": payload["message_id"], @@ -1010,10 +1005,28 @@ async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]: "MESSAGES[0][message][text]": message["text"], "MESSAGES[0][chat][id]": payload["external_chat_id"], } - if message["files"]: + phone = payload["user"].get("phone") + if phone: + fields["MESSAGES[0][user][phone]"] = phone + if message.get("files"): file = message["files"][0] fields["MESSAGES[0][message][files][0][url]"] = file["download_url"] fields["MESSAGES[0][message][files][0][name]"] = file["name"] + return fields + + +async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]: + async with app.state.sessions() as session: + row = await session.get(OutboundMessage, row_id) + portal = await active_portal(session) + if not row or not portal: + raise RuntimeError("portal_not_installed") + payload = row.payload_json + fields = imconnector_send_fields( + app.state.settings.bitrix_connector_id, + app.state.settings.bitrix_open_line_id, + payload, + ) result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields) chat_id, session_id, bitrix_message_id = extract_delivery(result) response = { diff --git a/VM1_app/codebase/backend/bitrix-local-app/openapi.yaml b/VM1_app/codebase/backend/bitrix-local-app/openapi.yaml index 2d93dee..4d5da22 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/openapi.yaml +++ b/VM1_app/codebase/backend/bitrix-local-app/openapi.yaml @@ -91,6 +91,7 @@ components: properties: id: {type: string, format: uuid} display_name: {type: string, maxLength: 255} + phone: {type: string, minLength: 1, maxLength: 32} message: type: object additionalProperties: false diff --git a/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py b/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py index 59f7f90..6a34fc4 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py +++ b/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py @@ -23,6 +23,7 @@ from app.main import ( BitrixClient, TokenCipher, canonical_fingerprint, + imconnector_send_fields, normalize_event, resolve_inbound_file_urls, retry_delay, @@ -278,3 +279,23 @@ async def test_bitrix_call_refreshes_and_retries_once_after_401(): assert result == {"ok": True} assert auth_values == ["old-access", "new-access"] assert refresh_calls == [False, True] + + +def test_imconnector_send_fields_maps_name_and_phone(): + payload = { + "message_id": "mid", + "external_chat_id": "chat", + "occurred_at": "2026-07-10T09:00:00Z", + "user": {"id": "uid", "display_name": "Иван Иванов", "phone": "+79990000000"}, + "message": {"text": "hello", "files": []}, + } + fields = imconnector_send_fields("han_mobile_app", "8", payload) + assert fields["MESSAGES[0][user][name]"] == "Иван Иванов" + assert fields["MESSAGES[0][user][phone]"] == "+79990000000" + legacy = { + **payload, + "user": {"id": "uid", "display_name": "+79990000000"}, + } + legacy_fields = imconnector_send_fields("han_mobile_app", "8", legacy) + assert "MESSAGES[0][user][phone]" not in legacy_fields + assert legacy_fields["MESSAGES[0][user][name]"] == "+79990000000" diff --git a/VM1_app/codebase/backend/deployment/scripts/seed-personal-notifications-test.sh b/VM1_app/codebase/backend/deployment/scripts/seed-personal-notifications-test.sh index 3113d9f..890f4ab 100644 --- a/VM1_app/codebase/backend/deployment/scripts/seed-personal-notifications-test.sh +++ b/VM1_app/codebase/backend/deployment/scripts/seed-personal-notifications-test.sh @@ -1,58 +1,78 @@ #!/usr/bin/env bash # Создаёт 9 персональных уведомлений всех видов контура P для одного user_id. -# Запускать на ВМ из каталога backend: /opt/han-chat/backend +# Запускать на ВМ1 под root/admin (sudo -i). Секреты не читаются из /etc/han/vm1.env. # -# cd /opt/han-chat/backend -# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh -# chmod +x deployment/scripts/seed-personal-notifications-test.sh -# ./deployment/scripts/seed-personal-notifications-test.sh +# sed -i 's/\r$//' /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh +# chmod +x /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh +# /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh # -# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend. -# При старте api-backend синхронизирует hash токена в notification_sources. +# Токен: runtime-файл NOTIFICATIONS_TOKEN_PRODUCER_TEST +# (/run/han-chat/secrets/…, тот же, что у api-backend). Hash в notification_sources +# синхронизируется при старте api-backend. set -euo pipefail -cd "$(dirname "$0")/../.." +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +if [[ -f "${SCRIPT_DIR}/../../docker-compose.yml" ]]; then + cd "${SCRIPT_DIR}/../.." +else + cd "${HAN_DEPLOY_DIR:-/opt/han-chat/current/backend}" +fi -ENV_FILE="${ENV_FILE:-.env}" +CONFIG_FILE="${CONFIG_FILE:-/etc/han/vm1.env}" +SECRETS_LAUNCHER="${SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}" +if [[ ! -x "$SECRETS_LAUNCHER" ]]; then + SECRETS_LAUNCHER="deployment/secrets/han-secrets" +fi -if [[ ! -f "$ENV_FILE" ]]; then - echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2 +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "Не найден конфиг ${CONFIG_FILE} (ожидается /etc/han/vm1.env)." >&2 exit 1 fi -env_value() { - python3 - "$ENV_FILE" "$1" <<'PY' -import sys -from pathlib import Path +if [[ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]]; then + [[ -x "$SECRETS_LAUNCHER" ]] || { + echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2 + exit 66 + } + exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@" +fi -path, wanted = sys.argv[1:] -for raw in Path(path).read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - if key.strip() == wanted: - value = value.strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": - value = value[1:-1] - print(value) - break -else: - raise SystemExit(f"missing environment variable: {wanted}") -PY +compose() { + if [[ -x /usr/local/sbin/han-vm1-compose ]]; then + /usr/local/sbin/han-vm1-compose "$@" + else + docker compose --env-file "$CONFIG_FILE" "$@" + fi } trim_token() { printf '%s' "$1" | tr -d '\r\n\t ' } +read_secret_file() { + local path="${1:-}" + [[ -n "$path" && -r "$path" ]] || return 1 + python3 - "$path" <<'PY' +from pathlib import Path +import sys + +print(Path(sys.argv[1]).read_text(encoding="utf-8").strip()) +PY +} + TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")" if [[ -z "$TOKEN" ]]; then - TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")" + TOKEN="$(trim_token "$(read_secret_file "${NOTIFICATIONS_TOKEN_PRODUCER_TEST_FILE:-}" 2>/dev/null || true)")" fi if [[ -z "$TOKEN" ]]; then - read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN + TOKEN="$(trim_token "$(read_secret_file "${HAN_RUNTIME_SECRET_DIR:-/run/han-chat/secrets}/NOTIFICATIONS_TOKEN_PRODUCER_TEST" 2>/dev/null || true)")" +fi +if [[ -z "$TOKEN" ]]; then + TOKEN="$(trim_token "$(compose exec -T api-backend python3 -c 'import os; print(os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST") or "")' 2>/dev/null || true)")" +fi +if [[ -z "$TOKEN" ]]; then + read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (runtime secret не найден): " TOKEN echo TOKEN="$(trim_token "$TOKEN")" fi @@ -65,7 +85,7 @@ if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then exit 1 fi -echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)" +echo "Токен: ${#TOKEN} символов (значение не печатается)." PAYLOAD_DIR="$(mktemp -d)" trap 'rm -rf "$PAYLOAD_DIR"' EXIT @@ -103,22 +123,48 @@ create_notification() { fi echo echo "========== ${label} ==========" - docker run --rm \ - --network han-chat-backend \ - -v "${payload_file}:/payload.json:ro" \ - curlimages/curl:latest \ - -sS -i \ - -X POST \ - 'http://api-backend:8000/internal/notifications/v1/notifications' \ - -H "Authorization: Bearer ${TOKEN}" \ - -H 'Content-Type: application/json; charset=utf-8' \ - --data-binary @/payload.json + compose exec -T \ + -e "NOTIFICATIONS_PRODUCER_TOKEN=${TOKEN}" \ + api-backend python3 -c " +import os +import sys +import urllib.error +import urllib.request + +payload = sys.stdin.buffer.read() +request = urllib.request.Request( + 'http://127.0.0.1:8000/internal/notifications/v1/notifications', + data=payload, + method='POST', + headers={ + 'Authorization': 'Bearer ' + os.environ['NOTIFICATIONS_PRODUCER_TOKEN'], + 'Content-Type': 'application/json; charset=utf-8', + }, +) +try: + with urllib.request.urlopen(request, timeout=60) as response: + sys.stdout.write('HTTP/1.1 %s\n' % response.status) + for key, value in response.headers.items(): + sys.stdout.write('%s: %s\n' % (key, value)) + sys.stdout.write('\n') + sys.stdout.write(response.read().decode('utf-8', errors='replace')) + sys.stdout.write('\n') +except urllib.error.HTTPError as exc: + sys.stdout.write('HTTP/1.1 %s\n' % exc.code) + for key, value in exc.headers.items(): + sys.stdout.write('%s: %s\n' % (key, value)) + sys.stdout.write('\n') + sys.stdout.write(exc.read().decode('utf-8', errors='replace')) + sys.stdout.write('\n') + if exc.code >= 500 or exc.code in (401, 403): + raise SystemExit(1) +" <"$payload_file" } prepare_docs_in_s3() { echo >&2 echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2 - docker compose --env-file "$ENV_FILE" exec -T \ + compose exec -T \ -e "USER_ID=${USER_ID}" \ -e "RUN_ID=${RUN_ID}" \ api-backend python3 - <<'PY' @@ -443,6 +489,7 @@ echo echo "========== Готово ==========" echo "External ID prefix: ${RUN_ID}-*" echo -echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend." -echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env" -echo " docker compose --env-file .env up -d --force-recreate api-backend" +echo "Если видите 401: проверьте runtime-секрет NOTIFICATIONS_TOKEN_PRODUCER_TEST" +echo "и hash в han_app.notification_sources, затем пересоздайте api-backend:" +echo " test -r /run/han-chat/secrets/NOTIFICATIONS_TOKEN_PRODUCER_TEST" +echo " /usr/local/sbin/han-vm1-compose up -d --force-recreate api-backend" diff --git a/VM1_app/codebase/backend/frontend-test-site/app/profile.tsx b/VM1_app/codebase/backend/frontend-test-site/app/profile.tsx index 166a9ca..3c8b5ce 100644 --- a/VM1_app/codebase/backend/frontend-test-site/app/profile.tsx +++ b/VM1_app/codebase/backend/frontend-test-site/app/profile.tsx @@ -63,7 +63,7 @@ export default function ProfileScreen() { {fullName} - {personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"} + {personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Гражданство не указано"} {(profile.isLoading || documents.isLoading) && } diff --git a/VM1_app/documentation/module-06-bitrix-local-app.md b/VM1_app/documentation/module-06-bitrix-local-app.md index bc29980..9e9245e 100644 --- a/VM1_app/documentation/module-06-bitrix-local-app.md +++ b/VM1_app/documentation/module-06-bitrix-local-app.md @@ -111,7 +111,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна "occurred_at": "2026-07-10T09:00:00Z", "user": { "id": "uuid", - "display_name": "Клиент HAN" + "display_name": "Новый клиент HAN", + "phone": "+79990000000" }, "message": { "content_kind": "text", @@ -128,7 +129,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна "message_id": "uuid", "external_chat_id": "uuid", "occurred_at": "2026-07-10T09:00:00Z", - "user": {"id": "uuid", "display_name": "Клиент HAN"}, + "user": {"id": "uuid", "display_name": "Новый клиент HAN", "phone": "+79990000000"}, "message": { "content_kind": "file", "text": "", @@ -148,7 +149,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна - `external_chat_id` строго UUID и равен App `dialog_id`; - `text` xor один file; unknown fields запрещены; - signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости; -- PII профиля не требуется; телефон/email не передаются; +- `display_name` — `ClientProfile.full_name`, если пусто — `Новый клиент HAN`; +- телефон клиента передаётся в `user.phone` и мапится в `MESSAGES[0][user][phone]` для CRM-лида; email не передаётся; - fingerprint строится по стабильным полям без signed query; - тот же key/fingerprint возвращает прежний результат; - тот же key с иным fingerprint → `409 idempotency_key_reused`. @@ -198,7 +200,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна 1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`. 2. При completed вернуть сохранённый sanitized result. -3. Собрать `MESSAGES` Bitrix: `user.id`, `message.id/date/text/files`, `chat.id`. +3. Собрать `MESSAGES` Bitrix: `user.id/name/phone`, `message.id/date/text/files`, `chat.id`. 4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`. 5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа. 6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`. diff --git a/VM4_Expo-mobile/@anzh__han-chat.jks b/VM4_Expo-mobile/@anzh__han-chat.jks new file mode 100644 index 0000000..65d58d3 Binary files /dev/null and b/VM4_Expo-mobile/@anzh__han-chat.jks differ diff --git a/VM4_Expo-mobile/README.md b/VM4_Expo-mobile/README.md index f087180..4ec8d33 100644 --- a/VM4_Expo-mobile/README.md +++ b/VM4_Expo-mobile/README.md @@ -23,6 +23,73 @@ npm test Запуск приложения выполняется отдельно командой `npm run android`. Проект использует managed Expo workflow и не хранит каталоги `android/` и `ios/`. -## Сборка +## Сборка (EAS) -Профили development, preview и production определены в `eas.json`. Перед первой EAS-сборкой потребуется привязать Expo-проект и настроить signing credentials. +Профили заданы в `eas.json`. Env для билда берётся из профиля (`eas.json` → `env`), а не из локального `.env`. + +| Профиль | Артефакт | API | `EXPO_PUBLIC_APP_ENV` | +|---|---|---|---| +| `preview` | APK (внутренняя установка) | `dev-chat.han0107.ru` | `production-like` (есть «Диагностика») | +| `production` | AAB (Google Play) | `chat.han0107.ru` | `production` (без диагностики) | +| `development` | Dev Client | из окружения/локально | для разработки с `expo-dev-client` | + +### Один раз: вход в Expo + +```powershell +cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile +npx eas-cli login +npx eas-cli whoami +``` + +Проект уже привязан (`owner: anzh`, `projectId` в `app.config.ts`). Android keystore хранится remote на Expo. + +### Перед сборкой (рекомендуется) + +```powershell +npm run typecheck +npm test +``` + +### Preview — APK на телефон + +```powershell +npx eas-cli build --profile preview --platform android +``` + +После успеха откройте ссылку из вывода CLI (или `https://expo.dev/accounts/anzh/projects/han-chat/builds`) и скачайте APK. + +### Production — AAB для Play Store + +```powershell +npx eas-cli build --profile production --platform android +``` + +`versionCode` поднимается автоматически (`appVersionSource: remote` + `autoIncrement`). + +### Полезные команды + +```powershell +# только отправить билд и сразу вернуть ссылку (не ждать окончания) +npx eas-cli build --profile production --platform android --no-wait + +# статус конкретной сборки +npx eas-cli build:view + +# список последних сборок +npx eas-cli build:list --platform android --limit 5 + +# скачать артефакт готовой сборки +npx eas-cli build:download --id + +# синхронизировать versionCode с Play Console (если нужно вручную) +npx eas-cli build:version:set +``` + +### Keycloak redirect URI + +Для боевого хоста в клиенте `han-chat-frontend` должны быть exact URI: + +- `https://chat.han0107.ru/mobile/oidc/callback` +- `han-chat://auth/callback` + +Для preview — те же пути на `https://dev-chat.han0107.ru`. diff --git a/VM4_Expo-mobile/app/profile.tsx b/VM4_Expo-mobile/app/profile.tsx index f4d30f6..b817ff3 100644 --- a/VM4_Expo-mobile/app/profile.tsx +++ b/VM4_Expo-mobile/app/profile.tsx @@ -65,7 +65,7 @@ export default function ProfileScreen() { {fullName} - {personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"} + {personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Гражданство не указано"} {(profile.isLoading || documents.isLoading) && } diff --git a/VM4_Expo-mobile/eas.json b/VM4_Expo-mobile/eas.json index 6b7bdb3..eac0375 100644 --- a/VM4_Expo-mobile/eas.json +++ b/VM4_Expo-mobile/eas.json @@ -22,6 +22,16 @@ "EXPO_PUBLIC_APP_ENV": "production-like" } }, - "production": {} + "production": { + "distribution": "store", + "autoIncrement": true, + "env": { + "EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru", + "EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth", + "EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat", + "EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend", + "EXPO_PUBLIC_APP_ENV": "production" + } + } } } diff --git a/support¬es/backlog.md b/support¬es/backlog.md index 15e1bf3..0ae353a 100644 --- a/support¬es/backlog.md +++ b/support¬es/backlog.md @@ -31,20 +31,9 @@ ## #BACK_DEFECT Исправлены дублирующиеся триггеры на создание контакта для сервиса синхронизации. Исправлено создание в БД лишних задач на обновление контакта (каждый бустрап пользователя вызывал задачу на обновление контакта) # Закрыто 18.08-24.08 +## #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel ## #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.) - -# В разработку: - -1 #BACK_SECURE После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. (сейчас есть Фиксированный cooldownmin_seconds_between_attempts) -2. #MONITORING Настроить мониторинг в Signoz -3. #BACK_BUSINESS Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение) -4. #BACK_BUSINESS Веб-пуши для PWA -5. #UI На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at) -6. #BACK_BUSINESS Описание бизнес сущностей: Пользователь -7. #BACK_BUSINESS Описание бизнес сущностей: Сообщение -8. #UI Сделать страницу с инструкцией по установке приложения -9. #LEGAL Написать пользовательское соглашение. -10. #BACK_BUSINESS Разработка Message Safety v2 по [`module-05`](modules/module-05-message-safety.md), §18 DoR/DoD и cutover gates [`module-10-vm2`](modules/module-10-deployment-vm2.md): +## #BACK_BUSINESS Разработка Message Safety v2 по [`module-05`](modules/module-05-message-safety.md), §18 DoR/DoD и cutover gates [`module-10-vm2`](modules/module-10-deployment-vm2.md): - API/OpenAPI v2, versioned `message_safety.config_versions`, configuration activation/validation и schema migrations; - PostgreSQL queue/lease/fencing/deadline + Redis hot cache/rate/wakeup; - Unicode normalization и versioned text rule bundle/corpus; @@ -55,8 +44,21 @@ - VM2 internal nginx/TLS/egress/collector/dashboards + root-owned emergency MOCK helper/alert; - contract/security/failure/load acceptance и S3 negative gate; - controlled v1→v2 cutover, rollback rehearsal и удаление stub references. -11. #BACK_BUSINESS Разработка sync-service -12. #INFRASTRUCTURE Перераскатить сервисы от деплоя +## #BACK_BUSINESS Разработка sync-service +## #INFRASTRUCTURE Перераскатить сервисы от деплоя + + + +# В разработку: +1 #BACK_SECURE После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. (сейчас есть Фиксированный cooldownmin_seconds_between_attempts) +2. #MONITORING Настроить мониторинг в Signoz +3. #BACK_BUSINESS Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение) +4. #BACK_BUSINESS Веб-пуши для PWA +5. #UI На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at) +6. #BACK_BUSINESS Описание бизнес сущностей: Пользователь +7. #BACK_BUSINESS Описание бизнес сущностей: Сообщение +8. #UI Сделать страницу с инструкцией по установке приложения +9. #LEGAL Написать пользовательское соглашение. 13. #INFRASTRUCTURE Поднять второй контур для продакшн 14. #INFRASTRUCTURE Спрятать сеть за балансировщиком нагрузки 15. #UI Добавить подсказку по разрешенным типам файлов @@ -66,14 +68,11 @@ 19. #MONITORING Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.). 20. #MONITORING Nginx metrics/tracing в signoz 21. #BACK_SECURE Сформулировать требования для обработки персональных данных -22. #UI Скрыть раздел диагностики в профиле пользователя (наличие этого раздела в енв передать, как часть наследования продуктовой среды?) 23. #BACK_BUSINESS Разработка notification-service 24. #BACK_BUSINESS Store-review вход: точечный bypass в Keycloak OTP SPI по номеру из `.env` (`STORE_REVIEW_ENABLED` / `STORE_REVIEW_PHONE` / `STORE_REVIEW_OTP`) — для этого телефона SMS не шлётся, verify принимает фиксированный OTP; остальные номера идут обычным OTP/SMS. Не путать с глобальным `KEYCLOAK_OTP_MOCK_*`. Учётные данные только в Review Notes стора (не в бинарнике/UI); пользователь с демо-контентом; в production включать только на время ревью. 25. #UI Реализация мнемоник: Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?) 26. #INFRASTRUCTURE Развернуть Гит в облаке 27. #BACK_BUSINESS Синхронизация документов из битрикс24 в Приложение. -28. #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel -29. #BACK_BUSINESS Определить пул тестовых номеров, чтобы их было легко в Б24 отслеживать. 30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true) 31. #BACK_BUSINESS VM1 -> VM2: `curl -sS --cacert /etc/han/ca/vm2-internal-ca.crt "https://processing.internal:8443/internal/safety/status" | python3 -m json.tool` В коде захардкожен Redis в components = "degraded". Надо реализовать реальную проверку вместо костыля. 32. #BACK_BUSINESS Решить проблему с обновлением сигнатур CLAMAV. diff --git a/support¬es/faq.md b/support¬es/faq.md index 70d7608..bae264e 100644 --- a/support¬es/faq.md +++ b/support¬es/faq.md @@ -33,10 +33,38 @@ bitrix_sync.request_bitrix_contact_rebind( operator_id ) -# Копирование проекта на ВМ +# Публикация в сторе +Скачать keystore из Expo и выгрузить сертификат +cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile +npx eas-cli credentials -p android +Либо проще тут https://expo.dev/accounts/anzh/projects/han-chat/credentials/android/ru.han.chat -# Копирование отдельного файла на ВМ +Нужны 3 значения: +Keystore password +Key alias +Key password ++ архив с @anzh__han-chat-keystore.bak.jks +Далее создаем папку +идем в нее из терминала cd ........ + +Туда скачиваем из рустора pepk.jar + +И даем две команды +java -jar pepk.jar ` + --keystore=.\@anzh__han-chat-keystore.bak.jks ` + --alias=<значение от expo> ` + --output=.\pepk_out.zip ` + --encryptionkey= <значение из стора>` + --include-cert + + keytool -exportcert ` + -alias <значение от expo> ` + -keystore .\@anzh__han-chat-keystore.bak.jks ` + -rfc ` + -file .\upload_certificate.pem + +Готовые файлы заливаем уже в рустор \ No newline at end of file diff --git a/support¬es/scripts/popular_questions.md b/support¬es/scripts/popular_questions.md new file mode 100644 index 0000000..a888b9d --- /dev/null +++ b/support¬es/scripts/popular_questions.md @@ -0,0 +1,17 @@ +INSERT INTO han_app.popular_questions ( + id, + mnemonic, + locale, + question_text, + sort_order, + record_status +) +VALUES + (gen_random_uuid(), 'popular.patent_check', 'ru', 'Проверить патент', 20, 'A'), + (gen_random_uuid(), 'popular.rkl_check', 'ru', 'Проверить в РКЛ', 30, 'A') +ON CONFLICT (mnemonic, locale) DO UPDATE +SET + question_text = EXCLUDED.question_text, + sort_order = EXCLUDED.sort_order, + record_status = 'A', + updated_at = now(); \ No newline at end of file diff --git a/support¬es/scripts/public_notifications.md b/support¬es/scripts/public_notifications.md new file mode 100644 index 0000000..94927e2 --- /dev/null +++ b/support¬es/scripts/public_notifications.md @@ -0,0 +1,137 @@ +# Создание уведомлений + +## Публичные (инсерт в БД) + +BEGIN; + +INSERT INTO han_app.guest_notifications ( + id, + notification_type, + notification_datetime, + header, + text, + priority_override, + date_expired, + price, + old_price, + instruction_url, + chat_message_text, + lifecycle_status, + closed_at, + record_status, + status_changed_at, + status_change_reason, + created_at, + updated_at, + updater_user_id +) +VALUES +-- 1. Авторизация +( + '019fa3e9-8d91-7199-8199-609916d48f04', + 'authorize', + now(), + 'Войдите в личный кабинет', + 'Авторизуйтесь, чтобы всегда иметь возможность обратиться к экспертам', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +), + +-- 2. Установка приложения +( + '019fa3e9-8d91-722a-90df-e4824e23b354', + 'install_app', + now(), + 'Установите приложение HAN', + 'Добавьте приложение на главный экран, чтобы сервис всегда был под рукой.', + NULL, + NULL, + NULL, + NULL, + 'https://www.han0107.ru/app/how-install-pwa', + NULL, + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +), + +-- 3. Глобальная акция +( + '019fa3e9-8d91-7d19-8873-eefb2a60f116', + 'promo_global', + now(), + 'Специальное предложение', + 'Узнайте подробнее об актуальной акции.', + NULL, + now() + interval '30 days', + NULL, + NULL, + NULL, + 'Здравствуйте! Хочу узнать подробнее об акции.', + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +), + +-- 4. Глобальное предложение +( + '019fa3e9-8d91-7452-a13d-3d2518ea257d', + 'ads_global', + now(), + 'Нужна помощь?', + 'Расскажем об услугах и подберём подходящее решение.', + NULL, + now() + interval '30 days', + NULL, + NULL, + NULL, + 'Здравствуйте! Хочу получить консультацию по услугам.', + 'active', + NULL, + 'A', + NULL, + NULL, + now(), + now(), + NULL +) +ON CONFLICT (id) DO UPDATE SET + notification_datetime = EXCLUDED.notification_datetime, + header = EXCLUDED.header, + text = EXCLUDED.text, + priority_override = EXCLUDED.priority_override, + date_expired = EXCLUDED.date_expired, + price = EXCLUDED.price, + old_price = EXCLUDED.old_price, + instruction_url = EXCLUDED.instruction_url, + chat_message_text = EXCLUDED.chat_message_text, + lifecycle_status = 'active', + closed_at = NULL, + record_status = 'A', + status_changed_at = now(), + status_change_reason = 'guest_campaign_republished', + updated_at = now(); + +COMMIT; \ No newline at end of file