Косметические правки по фронту, раскатка приложения в продакшн версии, исправление бага с созданием контакта
This commit is contained in:
@@ -117,6 +117,67 @@ async def ensure_delivery_outbox(
|
|||||||
return 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):
|
class DomainError(Exception):
|
||||||
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
||||||
self.code, self.status, self.message = code, status, message
|
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"
|
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
|
||||||
)
|
)
|
||||||
message.safety_status = "allowed"
|
message.safety_status = "allowed"
|
||||||
|
profile = await load_active_client_profile(session, user.id)
|
||||||
outbox = await ensure_delivery_outbox(
|
outbox = await ensure_delivery_outbox(
|
||||||
session,
|
session,
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
external_chat_id=dialog_id,
|
external_chat_id=dialog_id,
|
||||||
payload_json={
|
payload_json=openlines_delivery_payload(
|
||||||
"message_id": str(message.id),
|
message=message,
|
||||||
"external_chat_id": str(dialog_id),
|
dialog_id=dialog_id,
|
||||||
"occurred_at": message.occurred_at.isoformat(),
|
user=user,
|
||||||
"user": {"id": str(user.id), "display_name": user.phone_number},
|
profile=profile,
|
||||||
"message": {
|
attachment=attachment,
|
||||||
"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 []
|
|
||||||
),
|
),
|
||||||
},
|
|
||||||
},
|
|
||||||
# Keep the row recoverable after a process crash, but do not let the
|
# Keep the row recoverable after a process crash, but do not let the
|
||||||
# delivery worker race the synchronous first attempt.
|
# delivery worker race the synchronous first attempt.
|
||||||
next_attempt_at=datetime.now(UTC)
|
next_attempt_at=datetime.now(UTC)
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ from app.notification_service import expire_notifications
|
|||||||
from app.realtime import RealtimeFanout
|
from app.realtime import RealtimeFanout
|
||||||
from app.services import (
|
from app.services import (
|
||||||
ensure_delivery_outbox,
|
ensure_delivery_outbox,
|
||||||
|
load_active_client_profile,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
openlines_delivery_payload,
|
||||||
publish_dialog_status,
|
publish_dialog_status,
|
||||||
publish_message_status,
|
publish_message_status,
|
||||||
)
|
)
|
||||||
@@ -189,37 +191,18 @@ async def safety_once(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
if dialog and user:
|
if dialog and user:
|
||||||
|
profile = await load_active_client_profile(session, user.id)
|
||||||
await ensure_delivery_outbox(
|
await ensure_delivery_outbox(
|
||||||
session,
|
session,
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
external_chat_id=dialog.id,
|
external_chat_id=dialog.id,
|
||||||
payload_json={
|
payload_json=openlines_delivery_payload(
|
||||||
"message_id": str(message.id),
|
message=message,
|
||||||
"external_chat_id": str(dialog.id),
|
dialog_id=dialog.id,
|
||||||
"occurred_at": message.occurred_at.isoformat(),
|
user=user,
|
||||||
"user": {
|
profile=profile,
|
||||||
"id": str(user.id),
|
attachment=attachment,
|
||||||
"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 []
|
|
||||||
),
|
),
|
||||||
},
|
|
||||||
},
|
|
||||||
next_attempt_at=datetime.now(UTC),
|
next_attempt_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
elif verdict["_status"] == 403 and message:
|
elif verdict["_status"] == 403 and message:
|
||||||
|
|||||||
@@ -304,7 +304,11 @@ async def test_openlines_payload_gets_fresh_download_url_without_storage_fields(
|
|||||||
"message_id": str(uuid.uuid4()),
|
"message_id": str(uuid.uuid4()),
|
||||||
"external_chat_id": str(uuid.uuid4()),
|
"external_chat_id": str(uuid.uuid4()),
|
||||||
"occurred_at": "2026-07-10T12:00:00+00:00",
|
"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": {
|
"message": {
|
||||||
"content_kind": "file",
|
"content_kind": "file",
|
||||||
"text": "",
|
"text": "",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.schemas import (
|
|||||||
from app.services import (
|
from app.services import (
|
||||||
MESSAGE_SAFETY_REPLIES,
|
MESSAGE_SAFETY_REPLIES,
|
||||||
ensure_delivery_outbox,
|
ensure_delivery_outbox,
|
||||||
|
openlines_user_payload,
|
||||||
safety_reply_message,
|
safety_reply_message,
|
||||||
safety_task_recovery_at,
|
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
|
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:
|
async def test_delivery_outbox_returns_concurrent_insert_winner() -> None:
|
||||||
existing = object()
|
existing = object()
|
||||||
session = SimpleNamespace(
|
session = SimpleNamespace(
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class UserDto(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
display_name: str = Field(min_length=1, max_length=255)
|
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):
|
class FileDto(BaseModel):
|
||||||
@@ -697,7 +698,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
"Idempotency-Key must equal message_id",
|
"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)
|
fp = canonical_fingerprint(body)
|
||||||
async with request.app.state.sessions() as session:
|
async with request.app.state.sessions() as session:
|
||||||
row = await session.scalar(
|
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]:
|
def imconnector_send_fields(connector: str, line: str, payload: dict[str, Any]) -> 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
|
|
||||||
message = payload["message"]
|
message = payload["message"]
|
||||||
fields: dict[str, Any] = {
|
fields: dict[str, Any] = {
|
||||||
"CONNECTOR": app.state.settings.bitrix_connector_id,
|
"CONNECTOR": connector,
|
||||||
"LINE": app.state.settings.bitrix_open_line_id,
|
"LINE": line,
|
||||||
"MESSAGES[0][user][id]": payload["user"]["id"],
|
"MESSAGES[0][user][id]": payload["user"]["id"],
|
||||||
"MESSAGES[0][user][name]": payload["user"]["display_name"],
|
"MESSAGES[0][user][name]": payload["user"]["display_name"],
|
||||||
"MESSAGES[0][message][id]": payload["message_id"],
|
"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][message][text]": message["text"],
|
||||||
"MESSAGES[0][chat][id]": payload["external_chat_id"],
|
"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]
|
file = message["files"][0]
|
||||||
fields["MESSAGES[0][message][files][0][url]"] = file["download_url"]
|
fields["MESSAGES[0][message][files][0][url]"] = file["download_url"]
|
||||||
fields["MESSAGES[0][message][files][0][name]"] = file["name"]
|
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)
|
result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields)
|
||||||
chat_id, session_id, bitrix_message_id = extract_delivery(result)
|
chat_id, session_id, bitrix_message_id = extract_delivery(result)
|
||||||
response = {
|
response = {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: {type: string, format: uuid}
|
id: {type: string, format: uuid}
|
||||||
display_name: {type: string, maxLength: 255}
|
display_name: {type: string, maxLength: 255}
|
||||||
|
phone: {type: string, minLength: 1, maxLength: 32}
|
||||||
message:
|
message:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.main import (
|
|||||||
BitrixClient,
|
BitrixClient,
|
||||||
TokenCipher,
|
TokenCipher,
|
||||||
canonical_fingerprint,
|
canonical_fingerprint,
|
||||||
|
imconnector_send_fields,
|
||||||
normalize_event,
|
normalize_event,
|
||||||
resolve_inbound_file_urls,
|
resolve_inbound_file_urls,
|
||||||
retry_delay,
|
retry_delay,
|
||||||
@@ -278,3 +279,23 @@ async def test_bitrix_call_refreshes_and_retries_once_after_401():
|
|||||||
assert result == {"ok": True}
|
assert result == {"ok": True}
|
||||||
assert auth_values == ["old-access", "new-access"]
|
assert auth_values == ["old-access", "new-access"]
|
||||||
assert refresh_calls == [False, True]
|
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"
|
||||||
|
|||||||
@@ -1,58 +1,78 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
|
# Создаёт 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$//' /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||||
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
|
# chmod +x /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||||
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
|
# /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||||
# ./deployment/scripts/seed-personal-notifications-test.sh
|
|
||||||
#
|
#
|
||||||
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
|
# Токен: runtime-файл NOTIFICATIONS_TOKEN_PRODUCER_TEST
|
||||||
# При старте api-backend синхронизирует hash токена в notification_sources.
|
# (/run/han-chat/secrets/…, тот же, что у api-backend). Hash в notification_sources
|
||||||
|
# синхронизируется при старте api-backend.
|
||||||
|
|
||||||
set -euo pipefail
|
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
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||||
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
|
echo "Не найден конфиг ${CONFIG_FILE} (ожидается /etc/han/vm1.env)." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
env_value() {
|
if [[ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]]; then
|
||||||
python3 - "$ENV_FILE" "$1" <<'PY'
|
[[ -x "$SECRETS_LAUNCHER" ]] || {
|
||||||
import sys
|
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||||
from pathlib import Path
|
exit 66
|
||||||
|
}
|
||||||
|
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
path, wanted = sys.argv[1:]
|
compose() {
|
||||||
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
if [[ -x /usr/local/sbin/han-vm1-compose ]]; then
|
||||||
line = raw.strip()
|
/usr/local/sbin/han-vm1-compose "$@"
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
else
|
||||||
continue
|
docker compose --env-file "$CONFIG_FILE" "$@"
|
||||||
key, value = line.split("=", 1)
|
fi
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trim_token() {
|
trim_token() {
|
||||||
printf '%s' "$1" | tr -d '\r\n\t '
|
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:-}")"
|
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
|
||||||
if [[ -z "$TOKEN" ]]; then
|
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
|
fi
|
||||||
if [[ -z "$TOKEN" ]]; then
|
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
|
echo
|
||||||
TOKEN="$(trim_token "$TOKEN")"
|
TOKEN="$(trim_token "$TOKEN")"
|
||||||
fi
|
fi
|
||||||
@@ -65,7 +85,7 @@ if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
|
echo "Токен: ${#TOKEN} символов (значение не печатается)."
|
||||||
|
|
||||||
PAYLOAD_DIR="$(mktemp -d)"
|
PAYLOAD_DIR="$(mktemp -d)"
|
||||||
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
||||||
@@ -103,22 +123,48 @@ create_notification() {
|
|||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo "========== ${label} =========="
|
echo "========== ${label} =========="
|
||||||
docker run --rm \
|
compose exec -T \
|
||||||
--network han-chat-backend \
|
-e "NOTIFICATIONS_PRODUCER_TOKEN=${TOKEN}" \
|
||||||
-v "${payload_file}:/payload.json:ro" \
|
api-backend python3 -c "
|
||||||
curlimages/curl:latest \
|
import os
|
||||||
-sS -i \
|
import sys
|
||||||
-X POST \
|
import urllib.error
|
||||||
'http://api-backend:8000/internal/notifications/v1/notifications' \
|
import urllib.request
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json; charset=utf-8' \
|
payload = sys.stdin.buffer.read()
|
||||||
--data-binary @/payload.json
|
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() {
|
prepare_docs_in_s3() {
|
||||||
echo >&2
|
echo >&2
|
||||||
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
|
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
|
||||||
docker compose --env-file "$ENV_FILE" exec -T \
|
compose exec -T \
|
||||||
-e "USER_ID=${USER_ID}" \
|
-e "USER_ID=${USER_ID}" \
|
||||||
-e "RUN_ID=${RUN_ID}" \
|
-e "RUN_ID=${RUN_ID}" \
|
||||||
api-backend python3 - <<'PY'
|
api-backend python3 - <<'PY'
|
||||||
@@ -443,6 +489,7 @@ echo
|
|||||||
echo "========== Готово =========="
|
echo "========== Готово =========="
|
||||||
echo "External ID prefix: ${RUN_ID}-*"
|
echo "External ID prefix: ${RUN_ID}-*"
|
||||||
echo
|
echo
|
||||||
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
|
echo "Если видите 401: проверьте runtime-секрет NOTIFICATIONS_TOKEN_PRODUCER_TEST"
|
||||||
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
|
echo "и hash в han_app.notification_sources, затем пересоздайте api-backend:"
|
||||||
echo " docker compose --env-file .env up -d --force-recreate 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"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function ProfileScreen() {
|
|||||||
<Feather name="user" size={40} color={colors.primaryForeground} />
|
<Feather name="user" size={40} color={colors.primaryForeground} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={stylesLocal.name}>{fullName}</Text>
|
<Text style={stylesLocal.name}>{fullName}</Text>
|
||||||
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"}</Text>
|
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Гражданство не указано"}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
|
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
"occurred_at": "2026-07-10T09:00:00Z",
|
"occurred_at": "2026-07-10T09:00:00Z",
|
||||||
"user": {
|
"user": {
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"display_name": "Клиент HAN"
|
"display_name": "Новый клиент HAN",
|
||||||
|
"phone": "+79990000000"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"content_kind": "text",
|
"content_kind": "text",
|
||||||
@@ -128,7 +129,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
"message_id": "uuid",
|
"message_id": "uuid",
|
||||||
"external_chat_id": "uuid",
|
"external_chat_id": "uuid",
|
||||||
"occurred_at": "2026-07-10T09:00:00Z",
|
"occurred_at": "2026-07-10T09:00:00Z",
|
||||||
"user": {"id": "uuid", "display_name": "Клиент HAN"},
|
"user": {"id": "uuid", "display_name": "Новый клиент HAN", "phone": "+79990000000"},
|
||||||
"message": {
|
"message": {
|
||||||
"content_kind": "file",
|
"content_kind": "file",
|
||||||
"text": "",
|
"text": "",
|
||||||
@@ -148,7 +149,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
- `external_chat_id` строго UUID и равен App `dialog_id`;
|
- `external_chat_id` строго UUID и равен App `dialog_id`;
|
||||||
- `text` xor один file; unknown fields запрещены;
|
- `text` xor один file; unknown fields запрещены;
|
||||||
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
|
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
|
||||||
- PII профиля не требуется; телефон/email не передаются;
|
- `display_name` — `ClientProfile.full_name`, если пусто — `Новый клиент HAN`;
|
||||||
|
- телефон клиента передаётся в `user.phone` и мапится в `MESSAGES[0][user][phone]` для CRM-лида; email не передаётся;
|
||||||
- fingerprint строится по стабильным полям без signed query;
|
- fingerprint строится по стабильным полям без signed query;
|
||||||
- тот же key/fingerprint возвращает прежний результат;
|
- тот же key/fingerprint возвращает прежний результат;
|
||||||
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
|
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
|
||||||
@@ -198,7 +200,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
|
|
||||||
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
|
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
|
||||||
2. При completed вернуть сохранённый sanitized result.
|
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`.
|
4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`.
|
||||||
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
|
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
|
||||||
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.
|
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.
|
||||||
|
|||||||
Binary file not shown.
@@ -23,6 +23,73 @@ npm test
|
|||||||
|
|
||||||
Запуск приложения выполняется отдельно командой `npm run android`. Проект использует managed Expo workflow и не хранит каталоги `android/` и `ios/`.
|
Запуск приложения выполняется отдельно командой `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 <BUILD_ID>
|
||||||
|
|
||||||
|
# список последних сборок
|
||||||
|
npx eas-cli build:list --platform android --limit 5
|
||||||
|
|
||||||
|
# скачать артефакт готовой сборки
|
||||||
|
npx eas-cli build:download --id <BUILD_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`.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function ProfileScreen() {
|
|||||||
<Feather name="user" size={40} color={colors.primaryForeground} />
|
<Feather name="user" size={40} color={colors.primaryForeground} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={stylesLocal.name}>{fullName}</Text>
|
<Text style={stylesLocal.name}>{fullName}</Text>
|
||||||
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"}</Text>
|
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Гражданство не указано"}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
|
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@
|
|||||||
"EXPO_PUBLIC_APP_ENV": "production-like"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-18
@@ -31,20 +31,9 @@
|
|||||||
## #BACK_DEFECT Исправлены дублирующиеся триггеры на создание контакта для сервиса синхронизации. Исправлено создание в БД лишних задач на обновление контакта (каждый бустрап пользователя вызывал задачу на обновление контакта)
|
## #BACK_DEFECT Исправлены дублирующиеся триггеры на создание контакта для сервиса синхронизации. Исправлено создание в БД лишних задач на обновление контакта (каждый бустрап пользователя вызывал задачу на обновление контакта)
|
||||||
|
|
||||||
# Закрыто 18.08-24.08
|
# Закрыто 18.08-24.08
|
||||||
|
## #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel
|
||||||
## #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
|
## #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
|
||||||
|
## #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):
|
||||||
# В разработку:
|
|
||||||
|
|
||||||
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):
|
|
||||||
- API/OpenAPI v2, versioned `message_safety.config_versions`, configuration activation/validation и schema migrations;
|
- API/OpenAPI v2, versioned `message_safety.config_versions`, configuration activation/validation и schema migrations;
|
||||||
- PostgreSQL queue/lease/fencing/deadline + Redis hot cache/rate/wakeup;
|
- PostgreSQL queue/lease/fencing/deadline + Redis hot cache/rate/wakeup;
|
||||||
- Unicode normalization и versioned text rule bundle/corpus;
|
- Unicode normalization и versioned text rule bundle/corpus;
|
||||||
@@ -55,8 +44,21 @@
|
|||||||
- VM2 internal nginx/TLS/egress/collector/dashboards + root-owned emergency MOCK helper/alert;
|
- VM2 internal nginx/TLS/egress/collector/dashboards + root-owned emergency MOCK helper/alert;
|
||||||
- contract/security/failure/load acceptance и S3 negative gate;
|
- contract/security/failure/load acceptance и S3 negative gate;
|
||||||
- controlled v1→v2 cutover, rollback rehearsal и удаление stub references.
|
- controlled v1→v2 cutover, rollback rehearsal и удаление stub references.
|
||||||
11. #BACK_BUSINESS Разработка sync-service
|
## #BACK_BUSINESS Разработка sync-service
|
||||||
12. #INFRASTRUCTURE Перераскатить сервисы от деплоя
|
## #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 Поднять второй контур для продакшн
|
13. #INFRASTRUCTURE Поднять второй контур для продакшн
|
||||||
14. #INFRASTRUCTURE Спрятать сеть за балансировщиком нагрузки
|
14. #INFRASTRUCTURE Спрятать сеть за балансировщиком нагрузки
|
||||||
15. #UI Добавить подсказку по разрешенным типам файлов
|
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.).
|
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
|
20. #MONITORING Nginx metrics/tracing в signoz
|
||||||
21. #BACK_SECURE Сформулировать требования для обработки персональных данных
|
21. #BACK_SECURE Сформулировать требования для обработки персональных данных
|
||||||
22. #UI Скрыть раздел диагностики в профиле пользователя (наличие этого раздела в енв передать, как часть наследования продуктовой среды?)
|
|
||||||
23. #BACK_BUSINESS Разработка notification-service
|
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 включать только на время ревью.
|
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 заливка мнемоник в БД (?)
|
25. #UI Реализация мнемоник: Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
|
||||||
26. #INFRASTRUCTURE Развернуть Гит в облаке
|
26. #INFRASTRUCTURE Развернуть Гит в облаке
|
||||||
27. #BACK_BUSINESS Синхронизация документов из битрикс24 в Приложение.
|
27. #BACK_BUSINESS Синхронизация документов из битрикс24 в Приложение.
|
||||||
28. #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel
|
|
||||||
29. #BACK_BUSINESS Определить пул тестовых номеров, чтобы их было легко в Б24 отслеживать.
|
|
||||||
30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true)
|
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". Надо реализовать реальную проверку вместо костыля.
|
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.
|
32. #BACK_BUSINESS Решить проблему с обновлением сигнатур CLAMAV.
|
||||||
|
|||||||
+30
-2
@@ -33,10 +33,38 @@ bitrix_sync.request_bitrix_contact_rebind(
|
|||||||
operator_id
|
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
|
||||||
|
|
||||||
|
Готовые файлы заливаем уже в рустор
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user