diff --git a/Makefile b/Makefile index db6c3ce..829b364 100644 --- a/Makefile +++ b/Makefile @@ -1,51 +1,134 @@ DOCKER_NETWORK := public-gateway-network +DOMAIN := writehub.space +EMAIL := admin@writehub.space +NGINX_COMPOSE := docker-compose.nginx.yml +FRONTEND_DIR := ../kotyari-bots_frontend -defalut: help - +# Автоматический поиск сервисов для Ogen SERVICES := $(shell find ./docs -mindepth 2 -maxdepth 3 -type f -name 'openapi.yaml' -print \ | sed -e 's|^./docs/||' -e 's|/openapi.yaml$$||' | sort -u) export PATH := $(shell go env GOPATH)/bin:$(PATH) -.PHONY: help up down reboot test +.PHONY: help up down bots-up profiles-up posts-up gateway-up ssl-install frontend-build + +default: help help: @echo '' @echo 'usage: make [target]' @echo '' - @echo 'targets:' - @echo ' download-lint - Downloading linter binary' - @echo ' check-lint - Verify linter version (>= 2)' - @echo ' verify-lint-config - Verifies linter config' - @echo ' lint - running linter' - @echo ' download-gci - Downloading import formatter' - @echo ' install - Download all dev tools (linter, formatter)' - @echo ' format - Format go import statements' - @echo ' format-check - Check go import statements formatting' - @echo ' check - Run all checks (lint, format-check)' - @echo "api - Сгенерировать Go-код из всех openapi.yml файлов." - @echo "install-ogen - Установить или обновить генератор кода ogen." - -# --- Вспомогательные и внутренние команды --- - -.PHONY: setup-network teardown-network copy-env + @echo 'MAIN TARGETS:' + @echo ' up - Поднять весь бэкенд (БД, Kafka, Go-сервисы) без Nginx' + @echo ' down - Остановить весь бэкенд' + @echo ' gateway-up - Поднять Nginx (Gateway) + Certbot' + @echo ' ssl-install - Полная настройка HTTPS (с генерацией сертификатов)' + @echo ' frontend-build - Собрать статику Nuxt и исправить права доступа' + @echo '' + @echo 'DEV TOOLS:' + @echo ' lint - Запустить линтер' + @echo ' format - Отформатировать импорты' + @echo ' api - Сгенерировать Go-код (Ogen) из OpenAPI' + @echo ' proto-build - Сгенерировать gRPC код из .proto' +# --- СЕТЬ И ОКРУЖЕНИЕ --- setup-network: @docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || \ (echo "Создаю общую Docker-сеть: $(DOCKER_NETWORK)..." && docker network create $(DOCKER_NETWORK)) -# Удаляет общую сеть -teardown-network: - @docker network rm $(DOCKER_NETWORK) >/dev/null 2>&1 || true - copy-env: @if [ ! -f .env ]; then \ echo "Создаю .env файл из .env.example..."; \ cp .env.example .env; \ fi -# --- Кодогенерация и статический анализ --- +# --- БЭКЕНД (Docker Compose) --- + +# Параллельный запуск основных сервисов +up: copy-env setup-network + @echo "Starting backend services..." + @$(MAKE) bots-up & \ + $(MAKE) profiles-up & \ + $(MAKE) posts-up & \ + wait + @echo "Backend services are up." + +down: + @echo "Stopping backend services..." + @$(MAKE) bots-down & \ + $(MAKE) profiles-down & \ + $(MAKE) posts-down & \ + wait + @echo "Backend services stopped." + +bots-up: setup-network + docker compose -f docker-compose.bots.yml up -d --build + +bots-down: + docker compose -f docker-compose.bots.yml down + +profiles-up: setup-network + docker compose -f docker-compose.profiles.yml up -d --build + +profiles-down: + docker compose -f docker-compose.profiles.yml down + +posts-up: setup-network + docker compose -f docker-compose.posts.yml up -d --build + +posts-down: + docker compose -f docker-compose.posts.yml down + +# --- FRONTEND --- + +frontend-build: + @echo "Building Frontend Static Site..." + cd $(FRONTEND_DIR) && npm run generate + @echo "Fixing permissions for Nginx..." + chmod -R 755 $(FRONTEND_DIR)/.output/public + @echo "Frontend built successfully." + +# --- GATEWAY & SSL (NGINX) --- + +gateway-up: setup-network + docker compose -f $(NGINX_COMPOSE) up -d + +gateway-down: + docker compose -f $(NGINX_COMPOSE) down + +gateway-restart: + docker compose -f $(NGINX_COMPOSE) restart gateway + +gateway-logs: + docker compose -f $(NGINX_COMPOSE) logs -f + +ssl-install: + @if [ ! -f nginx.conf.http ] || [ ! -f nginx.conf.https ]; then \ + echo "Ошибка: Файлы nginx.conf.http и nginx.conf.https должны существовать."; \ + exit 1; \ + fi + @echo ">>> [1/4] Применяем HTTP конфигурацию (для валидации)..." + cp nginx.conf.http nginx.conf + $(MAKE) gateway-up + @echo ">>> Ожидание запуска Nginx..." + @sleep 5 + @echo ">>> [2/4] Генерация сертификатов через Let's Encrypt..." + docker compose -f $(NGINX_COMPOSE) run --rm --entrypoint certbot certbot certonly --webroot --webroot-path /var/www/certbot \ + -d $(DOMAIN) -d www.$(DOMAIN) \ + --email $(EMAIL) \ + --agree-tos --no-eff-email --force-renewal + @echo ">>> [3/4] Применяем HTTPS конфигурацию (боевую)..." + cp nginx.conf.https nginx.conf + @echo ">>> [4/4] Перезагрузка Nginx..." + docker compose -f $(NGINX_COMPOSE) exec gateway nginx -s reload + @echo ">>> Готово. HTTPS настроен." + +cert-renew: + docker compose -f $(NGINX_COMPOSE) run --rm --entrypoint certbot certbot renew + docker compose -f $(NGINX_COMPOSE) exec gateway nginx -s reload + +# --- CODE GEN & LINTING --- PROTO_DIR := ./api/protos GEN_DIR := gen @@ -64,34 +147,27 @@ $(ENTITIES): --go-grpc_out=$(PROTO_DIR)/$@/$(GEN_DIR) \ --go-grpc_opt=paths=source_relative \ $(PROTO_DIR)/$@/proto/*.proto - @echo "Генерация для $@ завершена." + +install-ogen: + go install github.com/ogen-go/ogen/cmd/ogen@v1.16.0 api: install-ogen @echo "Начинаю генерацию кода для сервисов: $(SERVICES)" $(foreach service,$(SERVICES),$(call generate-service,$(service))) @echo "Генерация кода успешно завершена." -install-ogen: - go install github.com/ogen-go/ogen/cmd/ogen@v1.16.0 - define generate-service @echo "--- Генерирую код для сервиса: $(1) ---" $(eval INPUT_FILE := ./docs/$(1)/openapi.yaml) $(eval OUTPUT_DIR := ./internal/gen/$(1)) - $(eval PKG := $(notdir $(1))) # e.g., posts_1 + $(eval PKG := $(notdir $(1))) $(eval OGEN_CFG := ./docs/ogen-config.yaml) - @if [ ! -f "$(INPUT_FILE)" ]; then \ - echo "Ошибка: Файл спецификации $(INPUT_FILE) не найден!"; \ - exit 1; \ - fi - @mkdir -p "$(OUTPUT_DIR)" ogen --config "$(OGEN_CFG)" --target "$(OUTPUT_DIR)" --package "$(PKG)" -clean "$(INPUT_FILE)" endef - download-lint: - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.3.1 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v1.61.0 download-gci: go install github.com/daixiang0/gci@v0.13.4 @@ -109,79 +185,7 @@ format-check: check: lint format-check -# параллельно -up: copy-env setup-network - @echo "Starting services in parallel..." - @$(MAKE) bots-up & \ - $(MAKE) profiles-up & \ - $(MAKE) posts-up & \ - wait - @echo "All services are up and running." - -# параллельно -down: - @echo "Shutdown services in parallel..." - @$(MAKE) bots-down & \ - $(MAKE) profiles-down & \ - $(MAKE) posts-down & \ - wait - @echo "All services are up and stopped." - -bots-up: setup-network - @echo "Starting bots service and dependencies..." - @docker compose -f docker-compose.bots.yml up -d --build - -bots-down: - @echo "Stopping bots service and dependencies..." - @docker compose -f docker-compose.bots.yml down - -bots-reboot: - @echo "Rebooting bots service and dependencies..." - $(MAKE) bots-down - $(MAKE) bots-up - -profiles-up: setup-network - @echo "Starting profiles service and dependencies..." - docker compose -f docker-compose.profiles.yml up -d --build - -profiles-down: - @echo "Stopping profiles service and dependencies..." - @docker compose -f docker-compose.profiles.yml down - -profiles-reboot: - @echo "Rebooting profiles service and dependencies..." - $(MAKE) profiles-down - $(MAKE) profiles-up - -posts-up: setup-network - @echo "Starting posts service and dependencies..." - docker compose -f docker-compose.posts.yml up -d --build - -posts-down: - @echo "Stopping posts service and dependencies..." - @docker compose -f docker-compose.posts.yml down - -posts-reboot: - @echo "Rebooting posts service and dependencies..." - $(MAKE) posts-down - $(MAKE) posts-up - - -example-run: - @go run cmd/example/main.go -example-run-local: ## Запустить в local режиме - @go run cmd/example/main.go --env=local --config="./configs/local-config.yaml" - -example-run-prod: - @go run cmd/example/main.go --env=prod - -install-migrate: - @if ! command -v migrate &> /dev/null; then \ - echo "migrate CLI not found. Installing..."; \ - go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate@latest; \ - fi - -.PHONY: download-lint download-gci lint format format-check check help api +# --- INTRANET (Parsers) --- INTRANET_DIR := ./intranet @@ -191,18 +195,8 @@ intranet-up-dev: intranet-down-dev: $(MAKE) -C $(INTRANET_DIR) down-dev - intranet-up-prod: $(MAKE) -C $(INTRANET_DIR) up-prod intranet-down-prod: - $(MAKE) -C $(INTRANET_DIR) down-prod - -intranet-deps: - $(MAKE) -C $(INTRANET_DIR) deps - -intranet-test: - $(MAKE) -C $(INTRANET_DIR) test-detection-compose - -dzen-url-start: - curl -X POST http://localhost:8090/trigger-parsing + $(MAKE) -C $(INTRANET_DIR) down-prod \ No newline at end of file diff --git a/api/protos/bot_profile/gen/get_profiles.pb.go b/api/protos/bot_profile/gen/get_profiles.pb.go index 5499d0e..fe1cd3d 100644 --- a/api/protos/bot_profile/gen/get_profiles.pb.go +++ b/api/protos/bot_profile/gen/get_profiles.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.11 // protoc v6.32.0 // source: get_profiles.proto diff --git a/api/protos/bot_profile/gen/get_profiles_grpc.pb.go b/api/protos/bot_profile/gen/get_profiles_grpc.pb.go index 94df56a..1259310 100644 --- a/api/protos/bot_profile/gen/get_profiles_grpc.pb.go +++ b/api/protos/bot_profile/gen/get_profiles_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 +// - protoc-gen-go-grpc v1.6.0 // - protoc v6.32.0 // source: get_profiles.proto @@ -84,10 +84,10 @@ type ProfilesServiceServer interface { type UnimplementedProfilesServiceServer struct{} func (UnimplementedProfilesServiceServer) GetProfiles(context.Context, *GetProfilesRequest) (*GetProfilesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetProfiles not implemented") + return nil, status.Error(codes.Unimplemented, "method GetProfiles not implemented") } func (UnimplementedProfilesServiceServer) ProfilesExist(context.Context, *ProfilesExistRequest) (*ProfilesExistResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ProfilesExist not implemented") + return nil, status.Error(codes.Unimplemented, "method ProfilesExist not implemented") } func (UnimplementedProfilesServiceServer) mustEmbedUnimplementedProfilesServiceServer() {} func (UnimplementedProfilesServiceServer) testEmbeddedByValue() {} @@ -100,7 +100,7 @@ type UnsafeProfilesServiceServer interface { } func RegisterProfilesServiceServer(s grpc.ServiceRegistrar, srv ProfilesServiceServer) { - // If the following call pancis, it indicates UnimplementedProfilesServiceServer was + // If the following call panics, it indicates UnimplementedProfilesServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. diff --git a/api/protos/bots/gen/get_bot.pb.go b/api/protos/bots/gen/get_bot.pb.go index 4ce6b62..1c3fcaf 100644 --- a/api/protos/bots/gen/get_bot.pb.go +++ b/api/protos/bots/gen/get_bot.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.11 // protoc v6.32.0 // source: get_bot.proto @@ -22,12 +22,14 @@ const ( ) type Bot struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - BotPrompt string `protobuf:"bytes,2,opt,name=bot_prompt,json=botPrompt,proto3" json:"bot_prompt,omitempty"` - BotName string `protobuf:"bytes,3,opt,name=bot_name,json=botName,proto3" json:"bot_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + BotPrompt string `protobuf:"bytes,2,opt,name=bot_prompt,json=botPrompt,proto3" json:"bot_prompt,omitempty"` + BotName string `protobuf:"bytes,3,opt,name=bot_name,json=botName,proto3" json:"bot_name,omitempty"` + // Indicates whether posts created by this bot require moderation + ModerationRequired bool `protobuf:"varint,4,opt,name=moderation_required,json=moderationRequired,proto3" json:"moderation_required,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Bot) Reset() { @@ -81,6 +83,13 @@ func (x *Bot) GetBotName() string { return "" } +func (x *Bot) GetModerationRequired() bool { + if x != nil { + return x.ModerationRequired + } + return false +} + type GetBotRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -129,12 +138,13 @@ var File_get_bot_proto protoreflect.FileDescriptor const file_get_bot_proto_rawDesc = "" + "\n" + - "\rget_bot.proto\x12\x04bots\"O\n" + + "\rget_bot.proto\x12\x04bots\"\x80\x01\n" + "\x03Bot\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + "\n" + "bot_prompt\x18\x02 \x01(\tR\tbotPrompt\x12\x19\n" + - "\bbot_name\x18\x03 \x01(\tR\abotName\"\x1f\n" + + "\bbot_name\x18\x03 \x01(\tR\abotName\x12/\n" + + "\x13moderation_required\x18\x04 \x01(\bR\x12moderationRequired\"\x1f\n" + "\rGetBotRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id26\n" + "\n" + diff --git a/api/protos/bots/gen/get_bot_grpc.pb.go b/api/protos/bots/gen/get_bot_grpc.pb.go index 395a161..a697ca6 100644 --- a/api/protos/bots/gen/get_bot_grpc.pb.go +++ b/api/protos/bots/gen/get_bot_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 +// - protoc-gen-go-grpc v1.6.0 // - protoc v6.32.0 // source: get_bot.proto @@ -63,7 +63,7 @@ type BotServiceServer interface { type UnimplementedBotServiceServer struct{} func (UnimplementedBotServiceServer) GetBot(context.Context, *GetBotRequest) (*Bot, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetBot not implemented") + return nil, status.Error(codes.Unimplemented, "method GetBot not implemented") } func (UnimplementedBotServiceServer) mustEmbedUnimplementedBotServiceServer() {} func (UnimplementedBotServiceServer) testEmbeddedByValue() {} @@ -76,7 +76,7 @@ type UnsafeBotServiceServer interface { } func RegisterBotServiceServer(s grpc.ServiceRegistrar, srv BotServiceServer) { - // If the following call pancis, it indicates UnimplementedBotServiceServer was + // If the following call panics, it indicates UnimplementedBotServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. diff --git a/api/protos/bots/proto/get_bot.proto b/api/protos/bots/proto/get_bot.proto index 43b3df8..41b1189 100644 --- a/api/protos/bots/proto/get_bot.proto +++ b/api/protos/bots/proto/get_bot.proto @@ -12,6 +12,8 @@ message Bot { string id = 1; string bot_prompt = 2; string bot_name = 3; + // Indicates whether posts created by this bot require moderation + bool moderation_required = 4; } message GetBotRequest { diff --git a/api/protos/posts/gen/posts.pb.go b/api/protos/posts/gen/posts.pb.go index 8b21a56..e546cf7 100644 --- a/api/protos/posts/gen/posts.pb.go +++ b/api/protos/posts/gen/posts.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 -// protoc v6.32.1 +// protoc-gen-go v1.36.11 +// protoc v6.32.0 // source: posts.proto package gen @@ -221,6 +221,102 @@ func (x *GetPostsResponse) GetPostsResponse() []*GetPostResponse { return nil } +type ApprovePostRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + PostId string `protobuf:"bytes,1,opt,name=post_id,json=postId,proto3" json:"post_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApprovePostRequest) Reset() { + *x = ApprovePostRequest{} + mi := &file_posts_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApprovePostRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApprovePostRequest) ProtoMessage() {} + +func (x *ApprovePostRequest) ProtoReflect() protoreflect.Message { + mi := &file_posts_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApprovePostRequest.ProtoReflect.Descriptor instead. +func (*ApprovePostRequest) Descriptor() ([]byte, []int) { + return file_posts_proto_rawDescGZIP(), []int{4} +} + +func (x *ApprovePostRequest) GetPostId() string { + if x != nil { + return x.PostId + } + return "" +} + +type ApprovePostResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApprovePostResponse) Reset() { + *x = ApprovePostResponse{} + mi := &file_posts_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApprovePostResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApprovePostResponse) ProtoMessage() {} + +func (x *ApprovePostResponse) ProtoReflect() protoreflect.Message { + mi := &file_posts_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApprovePostResponse.ProtoReflect.Descriptor instead. +func (*ApprovePostResponse) Descriptor() ([]byte, []int) { + return file_posts_proto_rawDescGZIP(), []int{5} +} + +func (x *ApprovePostResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *ApprovePostResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + var File_posts_proto protoreflect.FileDescriptor const file_posts_proto_rawDesc = "" + @@ -239,10 +335,16 @@ const file_posts_proto_rawDesc = "" + "\x0fGetPostsRequest\x12:\n" + "\rposts_request\x18\x01 \x03(\v2\x15.posts.GetPostRequestR\fpostsRequest\"Q\n" + "\x10GetPostsResponse\x12=\n" + - "\x0eposts_response\x18\x01 \x03(\v2\x16.posts.GetPostResponseR\rpostsResponse2\x8a\x01\n" + + "\x0eposts_response\x18\x01 \x03(\v2\x16.posts.GetPostResponseR\rpostsResponse\"-\n" + + "\x12ApprovePostRequest\x12\x17\n" + + "\apost_id\x18\x01 \x01(\tR\x06postId\"I\n" + + "\x13ApprovePostResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage2\xd0\x01\n" + "\fPostsService\x128\n" + "\aGetPost\x12\x15.posts.GetPostRequest\x1a\x16.posts.GetPostResponse\x12@\n" + - "\rGetPostsBatch\x12\x16.posts.GetPostsRequest\x1a\x17.posts.GetPostsResponseB>ZZ posts.GetPostRequest 1, // 1: posts.GetPostsResponse.posts_response:type_name -> posts.GetPostResponse 0, // 2: posts.PostsService.GetPost:input_type -> posts.GetPostRequest 2, // 3: posts.PostsService.GetPostsBatch:input_type -> posts.GetPostsRequest - 1, // 4: posts.PostsService.GetPost:output_type -> posts.GetPostResponse - 3, // 5: posts.PostsService.GetPostsBatch:output_type -> posts.GetPostsResponse - 4, // [4:6] is the sub-list for method output_type - 2, // [2:4] is the sub-list for method input_type + 4, // 4: posts.PostsService.ApprovePost:input_type -> posts.ApprovePostRequest + 1, // 5: posts.PostsService.GetPost:output_type -> posts.GetPostResponse + 3, // 6: posts.PostsService.GetPostsBatch:output_type -> posts.GetPostsResponse + 5, // 7: posts.PostsService.ApprovePost:output_type -> posts.ApprovePostResponse + 5, // [5:8] is the sub-list for method output_type + 2, // [2:5] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name @@ -288,7 +394,7 @@ func file_posts_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_posts_proto_rawDesc), len(file_posts_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 6, NumExtensions: 0, NumServices: 1, }, diff --git a/api/protos/posts/gen/posts_grpc.pb.go b/api/protos/posts/gen/posts_grpc.pb.go index 50fbd53..ab64d8e 100644 --- a/api/protos/posts/gen/posts_grpc.pb.go +++ b/api/protos/posts/gen/posts_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v6.32.1 +// - protoc-gen-go-grpc v1.6.0 +// - protoc v6.32.0 // source: posts.proto package gen @@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( PostsService_GetPost_FullMethodName = "/posts.PostsService/GetPost" PostsService_GetPostsBatch_FullMethodName = "/posts.PostsService/GetPostsBatch" + PostsService_ApprovePost_FullMethodName = "/posts.PostsService/ApprovePost" ) // PostsServiceClient is the client API for PostsService service. @@ -29,6 +30,7 @@ const ( type PostsServiceClient interface { GetPost(ctx context.Context, in *GetPostRequest, opts ...grpc.CallOption) (*GetPostResponse, error) GetPostsBatch(ctx context.Context, in *GetPostsRequest, opts ...grpc.CallOption) (*GetPostsResponse, error) + ApprovePost(ctx context.Context, in *ApprovePostRequest, opts ...grpc.CallOption) (*ApprovePostResponse, error) } type postsServiceClient struct { @@ -59,12 +61,23 @@ func (c *postsServiceClient) GetPostsBatch(ctx context.Context, in *GetPostsRequ return out, nil } +func (c *postsServiceClient) ApprovePost(ctx context.Context, in *ApprovePostRequest, opts ...grpc.CallOption) (*ApprovePostResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApprovePostResponse) + err := c.cc.Invoke(ctx, PostsService_ApprovePost_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // PostsServiceServer is the server API for PostsService service. // All implementations must embed UnimplementedPostsServiceServer // for forward compatibility. type PostsServiceServer interface { GetPost(context.Context, *GetPostRequest) (*GetPostResponse, error) GetPostsBatch(context.Context, *GetPostsRequest) (*GetPostsResponse, error) + ApprovePost(context.Context, *ApprovePostRequest) (*ApprovePostResponse, error) mustEmbedUnimplementedPostsServiceServer() } @@ -76,10 +89,13 @@ type PostsServiceServer interface { type UnimplementedPostsServiceServer struct{} func (UnimplementedPostsServiceServer) GetPost(context.Context, *GetPostRequest) (*GetPostResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetPost not implemented") + return nil, status.Error(codes.Unimplemented, "method GetPost not implemented") } func (UnimplementedPostsServiceServer) GetPostsBatch(context.Context, *GetPostsRequest) (*GetPostsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetPostsBatch not implemented") + return nil, status.Error(codes.Unimplemented, "method GetPostsBatch not implemented") +} +func (UnimplementedPostsServiceServer) ApprovePost(context.Context, *ApprovePostRequest) (*ApprovePostResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApprovePost not implemented") } func (UnimplementedPostsServiceServer) mustEmbedUnimplementedPostsServiceServer() {} func (UnimplementedPostsServiceServer) testEmbeddedByValue() {} @@ -92,7 +108,7 @@ type UnsafePostsServiceServer interface { } func RegisterPostsServiceServer(s grpc.ServiceRegistrar, srv PostsServiceServer) { - // If the following call pancis, it indicates UnimplementedPostsServiceServer was + // If the following call panics, it indicates UnimplementedPostsServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -138,6 +154,24 @@ func _PostsService_GetPostsBatch_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _PostsService_ApprovePost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApprovePostRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PostsServiceServer).ApprovePost(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PostsService_ApprovePost_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PostsServiceServer).ApprovePost(ctx, req.(*ApprovePostRequest)) + } + return interceptor(ctx, in, info, handler) +} + // PostsService_ServiceDesc is the grpc.ServiceDesc for PostsService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -153,6 +187,10 @@ var PostsService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetPostsBatch", Handler: _PostsService_GetPostsBatch_Handler, }, + { + MethodName: "ApprovePost", + Handler: _PostsService_ApprovePost_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "posts.proto", diff --git a/api/protos/posts/proto/posts.proto b/api/protos/posts/proto/posts.proto index 6f75c05..348b925 100644 --- a/api/protos/posts/proto/posts.proto +++ b/api/protos/posts/proto/posts.proto @@ -7,6 +7,7 @@ option go_package = "github.com/goriiin/kotyari-bots_backend/api/protos/posts/ge service PostsService { rpc GetPost(GetPostRequest) returns (GetPostResponse); rpc GetPostsBatch(GetPostsRequest) returns (GetPostsResponse); + rpc ApprovePost(ApprovePostRequest) returns (ApprovePostResponse); } message GetPostRequest { @@ -27,3 +28,12 @@ message GetPostsRequest { message GetPostsResponse { repeated GetPostResponse posts_response = 1; } + +message ApprovePostRequest { + string post_id = 1; +} + +message ApprovePostResponse { + bool success = 1; + string message = 2; +} diff --git a/api/protos/profiles/gen/get_profiles.pb.go b/api/protos/profiles/gen/get_profiles.pb.go index 6a16e2e..a33650a 100644 --- a/api/protos/profiles/gen/get_profiles.pb.go +++ b/api/protos/profiles/gen/get_profiles.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.11 // protoc v6.32.0 // source: get_profiles.proto diff --git a/api/protos/profiles/gen/get_profiles_grpc.pb.go b/api/protos/profiles/gen/get_profiles_grpc.pb.go index 01cc88b..7f59dac 100644 --- a/api/protos/profiles/gen/get_profiles_grpc.pb.go +++ b/api/protos/profiles/gen/get_profiles_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 +// - protoc-gen-go-grpc v1.6.0 // - protoc v6.32.0 // source: get_profiles.proto @@ -76,10 +76,10 @@ type ProfileServiceServer interface { type UnimplementedProfileServiceServer struct{} func (UnimplementedProfileServiceServer) GetProfile(context.Context, *GetProfileRequest) (*Profile, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetProfile not implemented") + return nil, status.Error(codes.Unimplemented, "method GetProfile not implemented") } func (UnimplementedProfileServiceServer) BatchGetProfiles(context.Context, *BatchGetProfilesRequest) (*BatchGetProfilesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method BatchGetProfiles not implemented") + return nil, status.Error(codes.Unimplemented, "method BatchGetProfiles not implemented") } func (UnimplementedProfileServiceServer) mustEmbedUnimplementedProfileServiceServer() {} func (UnimplementedProfileServiceServer) testEmbeddedByValue() {} @@ -92,7 +92,7 @@ type UnsafeProfileServiceServer interface { } func RegisterProfileServiceServer(s grpc.ServiceRegistrar, srv ProfileServiceServer) { - // If the following call pancis, it indicates UnimplementedProfileServiceServer was + // If the following call panics, it indicates UnimplementedProfileServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. diff --git a/api/protos/url_fetcher/gen/start_fetching.pb.go b/api/protos/url_fetcher/gen/start_fetching.pb.go index 0a7fbe6..e544415 100644 --- a/api/protos/url_fetcher/gen/start_fetching.pb.go +++ b/api/protos/url_fetcher/gen/start_fetching.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.8 +// protoc-gen-go v1.36.11 // protoc v6.32.0 // source: start_fetching.proto diff --git a/api/protos/url_fetcher/gen/start_fetching_grpc.pb.go b/api/protos/url_fetcher/gen/start_fetching_grpc.pb.go index 639d244..0c73285 100644 --- a/api/protos/url_fetcher/gen/start_fetching_grpc.pb.go +++ b/api/protos/url_fetcher/gen/start_fetching_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 +// - protoc-gen-go-grpc v1.6.0 // - protoc v6.32.0 // source: start_fetching.proto @@ -64,7 +64,7 @@ type ProfileServiceServer interface { type UnimplementedProfileServiceServer struct{} func (UnimplementedProfileServiceServer) StartFetching(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method StartFetching not implemented") + return nil, status.Error(codes.Unimplemented, "method StartFetching not implemented") } func (UnimplementedProfileServiceServer) mustEmbedUnimplementedProfileServiceServer() {} func (UnimplementedProfileServiceServer) testEmbeddedByValue() {} @@ -77,7 +77,7 @@ type UnsafeProfileServiceServer interface { } func RegisterProfileServiceServer(s grpc.ServiceRegistrar, srv ProfileServiceServer) { - // If the following call pancis, it indicates UnimplementedProfileServiceServer was + // If the following call panics, it indicates UnimplementedProfileServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. diff --git a/configs/posts-local.yaml b/configs/posts-local.yaml index d386b85..03e32a5 100644 --- a/configs/posts-local.yaml +++ b/configs/posts-local.yaml @@ -28,7 +28,7 @@ posts_producer_reply: kind: consumer posts_consumer_grpc: - posts_addr: "host.docker.internal:50051" # Создать общий network между docker-compose.yml + posts_addr: "analytics:50051" # Создать общий network между docker-compose.yml dial_timeout: 10 posts_consumer_request: @@ -47,4 +47,13 @@ posts_consumer_reply: proxy: proxy_server: host: "xray-proxy" - port: 8200 \ No newline at end of file + port: 8200 + +posting_queue: + moderation_required: false + posting_interval: 30m + processing_interval: 1m + +otvet: + auth_token: "" # Set via LOCAL_OTVET_AUTH_TOKEN env var + request_timeout: 30s \ No newline at end of file diff --git a/docker-compose.bots.yml b/docker-compose.bots.yml index fb11dc2..5641eeb 100644 --- a/docker-compose.bots.yml +++ b/docker-compose.bots.yml @@ -52,6 +52,8 @@ services: - bots-internal-network - public-gateway-network depends_on: + bots_db: + condition: service_healthy bots_migrate: condition: service_completed_successfully restart: unless-stopped diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml index 938f006..1aa8e36 100644 --- a/docker-compose.nginx.yml +++ b/docker-compose.nginx.yml @@ -5,6 +5,8 @@ services: ports: - "80:80" - "443:443" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./certbot/conf:/etc/letsencrypt diff --git a/docker-compose.posts.yml b/docker-compose.posts.yml index b03634e..7f4f78f 100644 --- a/docker-compose.posts.yml +++ b/docker-compose.posts.yml @@ -62,6 +62,7 @@ services: networks: - posts-internal-network - public-gateway-network + - common-network depends_on: kafka: condition: service_healthy @@ -128,6 +129,8 @@ networks: driver: bridge public-gateway-network: external: true + common-network: + external: true volumes: posts_db_data: diff --git a/docs/posts/common/schemas.yaml b/docs/posts/common/schemas.yaml index 7c73608..0242d5b 100644 --- a/docs/posts/common/schemas.yaml +++ b/docs/posts/common/schemas.yaml @@ -57,6 +57,9 @@ Post: - "knowledge" - "history" description: "Тип поста" + task: + type: string + description: "Задание на генерацию от пользователя" title: type: string description: "Название поста" @@ -85,6 +88,7 @@ Post: - profileId - profileName - platform + - task - title - text - createdAt @@ -262,4 +266,30 @@ Error: text: "Поле не может быть пустым." required: - errorCode - - message \ No newline at end of file + - message + +PublishPostRequest: + type: object + description: "Данные для публикации поста." + properties: + approved: + type: boolean + description: "Одобрен ли пост для публикации" + example: true + required: + - approved + +PublishPostResponse: + type: object + description: "Результат публикации поста." + properties: + success: + type: boolean + description: "Успешно ли опубликован пост" + example: true + message: + type: string + description: "Сообщение о результате" + example: "Пост успешно опубликован" + required: + - success \ No newline at end of file diff --git a/docs/posts/posts_command/openapi.yaml b/docs/posts/posts_command/openapi.yaml index 1a9f937..3283d6f 100644 --- a/docs/posts/posts_command/openapi.yaml +++ b/docs/posts/posts_command/openapi.yaml @@ -122,5 +122,35 @@ paths: $ref: '../common/responses.yaml#/Unauthorized' '404': $ref: '../common/responses.yaml#/NotFound' + '500': + $ref: '../common/responses.yaml#/InternalServerError' + + /api/v1/posts/{postId}/publish: + post: + summary: "Опубликовать пост (для модерации)" + operationId: "publishPost" + tags: ["Posts"] + parameters: + - $ref: '../common/parameters.yaml#/PostID' + requestBody: + required: true + description: "Данные для публикации поста." + content: + application/json: + schema: + $ref: '../common/schemas.yaml#/PublishPostRequest' + responses: + '200': + description: "Пост успешно опубликован." + content: + application/json: + schema: + $ref: '../common/schemas.yaml#/PublishPostResponse' + '400': + $ref: '../common/responses.yaml#/BadRequest' + '401': + $ref: '../common/responses.yaml#/Unauthorized' + '404': + $ref: '../common/responses.yaml#/NotFound' '500': $ref: '../common/responses.yaml#/InternalServerError' \ No newline at end of file diff --git a/go.mod b/go.mod index d8521fe..32094d2 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.24.5 require ( github.com/fsnotify/fsnotify v1.8.0 github.com/go-faster/errors v0.7.1 - github.com/go-faster/jx v1.1.0 + github.com/go-faster/jx v1.2.0 github.com/golang-migrate/migrate/v4 v4.19.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 github.com/json-iterator/go v1.1.12 - github.com/ogen-go/ogen v1.16.0 + github.com/ogen-go/ogen v1.18.0 github.com/pkg/errors v0.9.1 github.com/rs/cors v1.11.1 github.com/rs/zerolog v1.34.0 @@ -20,10 +20,10 @@ require ( go.opentelemetry.io/otel v1.38.0 go.opentelemetry.io/otel/metric v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 - golang.org/x/net v0.44.0 - golang.org/x/sync v0.17.0 - google.golang.org/grpc v1.67.3 - google.golang.org/protobuf v1.36.1 + golang.org/x/net v0.48.0 + golang.org/x/sync v0.19.0 + google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.11 ) require ( @@ -40,7 +40,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect @@ -55,14 +55,14 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f26dc00..58b8831 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg= -github.com/go-faster/jx v1.1.0/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -50,6 +50,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -74,8 +76,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -99,8 +101,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw= -github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI= +github.com/ogen-go/ogen v1.18.0 h1:6RQ7lFBjOeNaUWu4getfqIh4GJbEY4hqKuzDtec/g60= +github.com/ogen-go/ogen v1.18.0/go.mod h1:dHFr2Wf6cA7tSxMI+zPC21UR5hAlDw8ZYUkK3PziURY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -113,8 +115,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -151,43 +153,49 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= -golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= -google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= -google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/apps/posts_command_consumer/config.go b/internal/apps/posts_command_consumer/config.go index 5e0ab90..16af19c 100644 --- a/internal/apps/posts_command_consumer/config.go +++ b/internal/apps/posts_command_consumer/config.go @@ -1,10 +1,13 @@ package posts_command_consumer import ( + "time" + "github.com/goriiin/kotyari-bots_backend/internal/delivery_grpc/posts_consumer_client" "github.com/goriiin/kotyari-bots_backend/internal/kafka" "github.com/goriiin/kotyari-bots_backend/pkg/config" "github.com/goriiin/kotyari-bots_backend/pkg/grok" + "github.com/goriiin/kotyari-bots_backend/pkg/otvet" "github.com/goriiin/kotyari-bots_backend/pkg/postgres" "github.com/goriiin/kotyari-bots_backend/pkg/proxy" ) @@ -15,6 +18,14 @@ type PostsCommandConsumerConfig struct { Database postgres.Config `mapstructure:"posts_database"` KafkaCons kafka.KafkaConfig `mapstructure:"posts_consumer_request"` KafkaProd kafka.KafkaConfig `mapstructure:"posts_consumer_reply"` + Otvet otvet.OtvetClientConfig `mapstructure:"otvet"` + PostingQueue PostingQueueConfig `mapstructure:"posting_queue"` +} + +type PostingQueueConfig struct { + ModerationRequired bool `mapstructure:"moderation_required"` + PostingInterval time.Duration `mapstructure:"posting_interval"` + ProcessingInterval time.Duration `mapstructure:"processing_interval"` } type LLMConfig struct { diff --git a/internal/apps/posts_command_consumer/init.go b/internal/apps/posts_command_consumer/init.go index 590d819..f899ac9 100644 --- a/internal/apps/posts_command_consumer/init.go +++ b/internal/apps/posts_command_consumer/init.go @@ -5,15 +5,19 @@ import ( "fmt" "time" + "github.com/go-faster/errors" "github.com/goriiin/kotyari-bots_backend/internal/delivery_grpc/posts_consumer_client" "github.com/goriiin/kotyari-bots_backend/internal/delivery_http/posts/posts_command_consumer" "github.com/goriiin/kotyari-bots_backend/internal/kafka" "github.com/goriiin/kotyari-bots_backend/internal/kafka/consumer" "github.com/goriiin/kotyari-bots_backend/internal/kafka/producer" + "github.com/goriiin/kotyari-bots_backend/internal/logger" postsRepoLib "github.com/goriiin/kotyari-bots_backend/internal/repo/posts/posts_command" "github.com/goriiin/kotyari-bots_backend/pkg/evals" "github.com/goriiin/kotyari-bots_backend/pkg/grok" + "github.com/goriiin/kotyari-bots_backend/pkg/otvet" "github.com/goriiin/kotyari-bots_backend/pkg/postgres" + "github.com/goriiin/kotyari-bots_backend/pkg/posting_queue" "github.com/goriiin/kotyari-bots_backend/pkg/rewriter" ) @@ -33,6 +37,8 @@ type PostsCommandConsumer struct { } func NewPostsCommandConsumer(config *PostsCommandConsumerConfig, llmConfig *LLMConfig) (*PostsCommandConsumer, error) { + log := logger.NewLogger("posts-command-consumer", &config.ConfigBase) + pool, err := postgres.GetPool(context.Background(), config.Database) if err != nil { return nil, err @@ -72,9 +78,63 @@ func NewPostsCommandConsumer(config *PostsCommandConsumerConfig, llmConfig *LLMC j := evals.NewJudge(cfg, grokClient) + otvetClient, err := otvet.NewOtvetClient(&config.Otvet) + if err != nil { + return nil, errors.Wrap(err, "failed to create otvet client") + } + + // Initialize posting queue + postingInterval := config.PostingQueue.PostingInterval + if postingInterval == 0 { + postingInterval = 30 * time.Minute // default + } + + processingInterval := config.PostingQueue.ProcessingInterval + if processingInterval == 0 { + processingInterval = 1 * time.Minute // default + } + + queue := posting_queue.NewQueue( + postingInterval, + processingInterval, + config.PostingQueue.ModerationRequired, + ) + + // Add account to queue (using auth token as account ID for now) + accountID := "default" // Can be extended to support multiple accounts + queue.AddAccount(accountID, config.Otvet.AuthToken, otvetClient) + + // Start queue processing in background + ctx := context.Background() + go queue.StartProcessing(ctx, publishPostFromQueue) + return &PostsCommandConsumer{ - consumerRunner: posts_command_consumer.NewPostsCommandConsumer(cons, repo, grpc, rw, j), + consumerRunner: posts_command_consumer.NewPostsCommandConsumer(cons, repo, grpc, rw, j, otvetClient, queue, log), consumer: cons, config: config, }, nil } + +// publishPostFromQueue publishes a post from the queue +func publishPostFromQueue(ctx context.Context, account *posting_queue.Account, queuedPost *posting_queue.QueuedPost) error { + if account.Client == nil { + return errors.New("account client is nil") + } + + otvetResp, err := account.Client.CreatePostSimple( + ctx, + queuedPost.Candidate.Title, + queuedPost.Candidate.Text, + queuedPost.Request.TopicType, + queuedPost.Request.Spaces, + ) + if err != nil { + return errors.Wrap(err, "failed to publish post from queue") + } + + if otvetResp != nil && otvetResp.Result != nil { + queuedPost.Post.OtvetiID = uint64(otvetResp.Result.ID) + } + + return nil +} diff --git a/internal/apps/posts_command_producer/init.go b/internal/apps/posts_command_producer/init.go index 0290608..70a9a28 100644 --- a/internal/apps/posts_command_producer/init.go +++ b/internal/apps/posts_command_producer/init.go @@ -18,6 +18,7 @@ type postsCommandHandler interface { CreatePost(ctx context.Context, req *gen.PostInput) (gen.CreatePostRes, error) UpdatePostById(ctx context.Context, req *gen.PostUpdate, params gen.UpdatePostByIdParams) (gen.UpdatePostByIdRes, error) DeletePostById(ctx context.Context, params gen.DeletePostByIdParams) (gen.DeletePostByIdRes, error) + PublishPost(ctx context.Context, req *gen.PublishPostRequest, params gen.PublishPostParams) (gen.PublishPostRes, error) SeenPosts(ctx context.Context, req *gen.PostsSeenRequest) (gen.SeenPostsRes, error) } diff --git a/internal/delivery_grpc/bots/get.go b/internal/delivery_grpc/bots/get.go index fca15fd..7c32aff 100644 --- a/internal/delivery_grpc/bots/get.go +++ b/internal/delivery_grpc/bots/get.go @@ -21,8 +21,9 @@ func (s *Server) GetBot(ctx context.Context, req *botgrpc.GetBotRequest) (*botgr } return &botgrpc.Bot{ - Id: botModel.ID.String(), - BotPrompt: botModel.SystemPrompt, - BotName: botModel.Name, + Id: botModel.ID.String(), + BotPrompt: botModel.SystemPrompt, + BotName: botModel.Name, + ModerationRequired: botModel.ModerationRequired, }, nil } diff --git a/internal/delivery_http/bots/delete.go b/internal/delivery_http/bots/delete.go index bf41a7d..8df7377 100644 --- a/internal/delivery_http/bots/delete.go +++ b/internal/delivery_http/bots/delete.go @@ -2,14 +2,17 @@ package bots import ( "context" + "log" gen "github.com/goriiin/kotyari-bots_backend/internal/gen/bots" ) func (h *Handler) DeleteBotById(ctx context.Context, params gen.DeleteBotByIdParams) (gen.DeleteBotByIdRes, error) { + log.Println("delete:", params.BotId) err := h.u.Delete(ctx, params.BotId) if err != nil { return nil, err } + return &gen.NoContent{}, nil } diff --git a/internal/delivery_http/bots/list.go b/internal/delivery_http/bots/list.go index 0b4ab92..8e982cb 100644 --- a/internal/delivery_http/bots/list.go +++ b/internal/delivery_http/bots/list.go @@ -2,21 +2,27 @@ package bots import ( "context" + "log" gen "github.com/goriiin/kotyari-bots_backend/internal/gen/bots" ) func (h *Handler) ListBots(ctx context.Context) (gen.ListBotsRes, error) { + log.Println("ListBots") bots, err := h.u.List(ctx) if err != nil { + log.Println(err) return nil, err } + log.Println("ListBots", bots) genBots := make([]gen.Bot, len(bots)) for i, b := range bots { genBots[i] = *modelToDTO(&b.Bot, b.Profiles) } + log.Println("bots list:", len(bots), genBots) + return &gen.BotList{ Data: genBots, NextCursor: gen.OptNilString{}, diff --git a/internal/delivery_http/posts/gen_dto.go b/internal/delivery_http/posts/gen_dto.go index 375af3b..fa1ac07 100644 --- a/internal/delivery_http/posts/gen_dto.go +++ b/internal/delivery_http/posts/gen_dto.go @@ -24,6 +24,7 @@ func QueryModelToHttp(post model.Post) *genQuery.Post { ProfileName: post.ProfileName, Platform: genQuery.PostPlatform(post.Platform), PostType: postType, + Task: post.UserPrompt, Title: post.Title, Text: post.Text, Categories: nil, @@ -61,6 +62,7 @@ func ModelToHttp(post model.Post) *genCommand.Post { ProfileName: post.ProfileName, Platform: genCommand.PostPlatform(post.Platform), PostType: postType, + Task: post.UserPrompt, Title: post.Title, Text: post.Text, Categories: nil, @@ -104,12 +106,10 @@ func HttpInputToModel(input genCommand.PostInput) (*model.Post, string) { } func PostsCheckModelToHttp(post model.Post) genQuery.PostsCheckObject { - isReady := !(post.Text == "" || post.Title == "") - return genQuery.PostsCheckObject{ ID: post.ID, GroupID: post.GroupID, - IsReady: isReady, + IsReady: post.Text != "" && post.Title != "", } } @@ -117,7 +117,6 @@ func PostsCheckModelsToHttpSlice(posts []model.Post) *genQuery.PostsCheckList { checkObjects := make([]genQuery.PostsCheckObject, 0, len(posts)) for _, post := range posts { - // TODO: Плакать хочется if post.IsSeen { continue diff --git a/internal/delivery_http/posts/kafka_dto.go b/internal/delivery_http/posts/kafka_dto.go index 4716eec..44670e2 100644 --- a/internal/delivery_http/posts/kafka_dto.go +++ b/internal/delivery_http/posts/kafka_dto.go @@ -8,10 +8,11 @@ import ( ) const ( - CmdCreate kafkaConfig.Command = "create" - CmdUpdate kafkaConfig.Command = "update" - CmdDelete kafkaConfig.Command = "delete" - CmdSeen kafkaConfig.Command = "seen" + CmdCreate kafkaConfig.Command = "create" + CmdUpdate kafkaConfig.Command = "update" + CmdDelete kafkaConfig.Command = "delete" + CmdPublish kafkaConfig.Command = "publish" + CmdSeen kafkaConfig.Command = "seen" ) // KafkaResponse TODO: model.Post -> []model.Post? @@ -33,6 +34,8 @@ type KafkaCreatePostRequest struct { Profiles []CreatePostProfiles `json:"profiles"` Platform model.PlatformType `json:"platform_type"` PostType model.PostType `json:"post_type"` + // ModerationRequired indicates whether posts from this bot require moderation before publishing + ModerationRequired bool `json:"moderation_required"` } type CreatePostProfiles struct { @@ -51,6 +54,11 @@ type KafkaSeenPostsRequest struct { PostIDs []uuid.UUID `json:"post_ids"` } +type KafkaPublishPostRequest struct { + PostID uuid.UUID `json:"post_id"` + Approved bool `json:"approved"` +} + func PayloadToEnvelope(command kafkaConfig.Command, entityID string, payload []byte) kafkaConfig.Envelope { return kafkaConfig.Envelope{ Command: command, @@ -66,17 +74,21 @@ func (r KafkaResponse) PostCommandToGen() *gen.Post { } return &gen.Post{ - ID: r.Post.ID, - OtvetiId: r.Post.OtvetiID, - BotId: r.Post.BotID, - ProfileId: r.Post.ProfileID, - Platform: gen.PostPlatform(r.Post.Platform), - PostType: postType, - Title: r.Post.Title, - Text: r.Post.Text, - Categories: nil, // TODO: ?? - CreatedAt: r.Post.CreatedAt, - UpdatedAt: r.Post.UpdatedAt, + ID: r.Post.ID, + OtvetiId: r.Post.OtvetiID, + BotId: r.Post.BotID, + BotName: r.Post.BotName, + ProfileId: r.Post.ProfileID, + ProfileName: r.Post.ProfileName, + GroupId: r.Post.GroupID, + Platform: gen.PostPlatform(r.Post.Platform), + PostType: postType, + Task: r.Post.UserPrompt, + Title: r.Post.Title, + Text: r.Post.Text, + Categories: nil, // TODO: ?? + CreatedAt: r.Post.CreatedAt, + UpdatedAt: r.Post.UpdatedAt, } } diff --git a/internal/delivery_http/posts/posts_command_consumer/create_post.go b/internal/delivery_http/posts/posts_command_consumer/create_post.go index e5e50df..2c72f0e 100644 --- a/internal/delivery_http/posts/posts_command_consumer/create_post.go +++ b/internal/delivery_http/posts/posts_command_consumer/create_post.go @@ -3,71 +3,28 @@ package posts_command_consumer import ( "context" "fmt" + "log" "sync" - "time" "github.com/go-faster/errors" "github.com/google/uuid" "github.com/goriiin/kotyari-bots_backend/internal/delivery_http/posts" "github.com/goriiin/kotyari-bots_backend/internal/model" + "github.com/goriiin/kotyari-bots_backend/pkg/otvet" + "github.com/goriiin/kotyari-bots_backend/pkg/posting_queue" ) func (p *PostsCommandConsumer) CreatePost(ctx context.Context, postsMap map[uuid.UUID]model.Post, req posts.KafkaCreatePostRequest) error { postsChan := make(chan model.Post, len(req.Profiles)) var wg sync.WaitGroup - for _, profile := range req.Profiles { wg.Add(1) - go func(prof posts.CreatePostProfiles) { + go func(profile posts.CreatePostProfiles) { defer wg.Done() - - profileCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - var mutex sync.Mutex - profileWg := sync.WaitGroup{} - profilesPosts := make([]model.Post, 0, 5) - - rewritten, err := p.rewriter.Rewrite(profileCtx, req.UserPrompt, prof.ProfilePrompt, req.BotPrompt) - if err != nil { - fmt.Println("error rewriting prompts:", err) - return - } - - for _, rw := range rewritten { - profileWg.Add(1) - go func(rewrittenText string) { - defer profileWg.Done() - - generatedPostContent, err := p.getter.GetPost(profileCtx, rewrittenText, prof.ProfilePrompt, req.BotPrompt) - if err != nil { - fmt.Println("error getting post:", err) - return - } - - post := postsMap[prof.ProfileID] - post.Title = generatedPostContent.PostTitle - post.Text = generatedPostContent.PostText - - mutex.Lock() - profilesPosts = append(profilesPosts, post) - mutex.Unlock() - }(rw) - } - - profileWg.Wait() - - bestPostCandidate, err := p.judge.SelectBest(profileCtx, req.UserPrompt, prof.ProfilePrompt, req.BotPrompt, posts.PostsToCandidates(profilesPosts)) - if err != nil { - fmt.Println("error getting best post:", err) - return + post := p.processProfile(ctx, req, profile, postsMap) + if post != nil { + postsChan <- *post } - - bestPost := postsMap[prof.ProfileID] - bestPost.Text = bestPostCandidate.Text - bestPost.Title = bestPostCandidate.Title - - postsChan <- bestPost }(profile) } @@ -83,8 +40,174 @@ func (p *PostsCommandConsumer) CreatePost(ctx context.Context, postsMap map[uuid err := p.repo.UpdatePostsBatch(ctx, finalPosts) if err != nil { - return errors.Wrap(err, "failed to update posts") + return errors.Wrap(err, "failed to create posts") } return nil } + +// processProfile processes a single profile and returns the best post +func (p *PostsCommandConsumer) processProfile(ctx context.Context, req posts.KafkaCreatePostRequest, profile posts.CreatePostProfiles, postsMap map[uuid.UUID]model.Post) *model.Post { + profilesPosts := p.generatePostsForProfile(ctx, req, profile, postsMap) + if len(profilesPosts) == 0 { + return nil + } + + bestPostCandidate, err := p.judge.SelectBest(ctx, req.UserPrompt, profile.ProfilePrompt, req.BotPrompt, + posts.PostsToCandidates(profilesPosts)) + if err != nil { + fmt.Println("error getting best post ", err) + return nil + } + + bestPost := postsMap[profile.ProfileID] + bestPost.Title = bestPostCandidate.Title + bestPost.Text = bestPostCandidate.Text + + // bestPost := p.createPostFromCandidate(req, profile, bestPostCandidate) + p.publishToOtvet(ctx, req, bestPostCandidate, &bestPost) + + return &bestPost +} + +// generatePostsForProfile generates multiple post candidates for a profile +func (p *PostsCommandConsumer) generatePostsForProfile(ctx context.Context, req posts.KafkaCreatePostRequest, profile posts.CreatePostProfiles, postsMap map[uuid.UUID]model.Post) []model.Post { + rewritten, err := p.rewriter.Rewrite(ctx, req.UserPrompt, profile.ProfilePrompt, req.BotPrompt) + if err != nil { + fmt.Println("error rewriting prompts", err) + return nil + } + + var ( + mutex sync.Mutex + profileWg sync.WaitGroup + ) + + profilesPosts := make([]model.Post, 0, len(rewritten)) + + for _, rw := range rewritten { + profileWg.Add(1) + go func(rewrittenPrompt string) { + defer profileWg.Done() + + generatedPostContent, err := p.getter.GetPost(ctx, rewrittenPrompt, profile.ProfilePrompt, req.BotPrompt) + if err != nil { + fmt.Println("error getting post", err) + return + } + + post := postsMap[profile.ProfileID] + post.Title = generatedPostContent.PostTitle + post.Text = generatedPostContent.PostText + + mutex.Lock() + profilesPosts = append(profilesPosts, post) + mutex.Unlock() + }(rw) + } + + profileWg.Wait() + return profilesPosts +} + +// publishToOtvet publishes post to otvet.mail.ru if platform is otveti +func (p *PostsCommandConsumer) publishToOtvet(ctx context.Context, req posts.KafkaCreatePostRequest, candidate model.Candidate, post *model.Post) { + if req.Platform != model.OtvetiPlatform || p.otvetClient == nil { + return + } + + topicType := getTopicTypeFromPostType(req.PostType) + spaces := p.getSpacesForPost(ctx, candidate) + + // If queue is available, add to queue instead of publishing directly + if p.queue != nil { + postRequest := posting_queue.PostRequest{ + Platform: req.Platform, + PostType: req.PostType, + TopicType: topicType, + Spaces: spaces, + // per-request moderation flag from bot + ModerationRequired: req.ModerationRequired, + } + p.queue.Enqueue(post, candidate, postRequest) + return + } + + // Fallback to direct publishing if queue is not available + // Respect bot-level moderation flag: if moderation required, do not publish directly + if req.ModerationRequired { + // Create post in DB but skip publish since moderation is required + fmt.Printf("bot requires moderation, skipping direct publish for post %s\n", post.ID.String()) + return + } + + otvetResp, err := p.otvetClient.CreatePostSimple(ctx, candidate.Title, candidate.Text, topicType, spaces) + if err != nil { + fmt.Printf("error publishing post to otvet: %v\n", err) + return + } + + log.Printf("INFO: published post to otvet: %v\n", otvetResp) + + if otvetResp != nil && otvetResp.Result != nil { + post.OtvetiID = uint64(otvetResp.Result.ID) + } +} + +// getSpacesForPost predicts spaces for a post or returns default spaces +func (p *PostsCommandConsumer) getSpacesForPost(ctx context.Context, candidate model.Candidate) []otvet.Space { + combinedText := candidate.Title + " " + candidate.Text + spaces := getDefaultSpaces() + + predictResp, err := p.otvetClient.PredictTagsSpaces(ctx, combinedText) + if err != nil { + fmt.Printf("error predicting spaces: %v, using default spaces\n", err) + return spaces + } + + if predictResp == nil || len(*predictResp) == 0 { + return spaces + } + + // Convert predicted spaces to Space format + predictedSpaces := make([]otvet.Space, 0, len((*predictResp)[0].Spaces)) + for _, spaceID := range (*predictResp)[0].Spaces { + predictedSpaces = append(predictedSpaces, otvet.Space{ + ID: spaceID, + IsPrime: true, + }) + } + + if len(predictedSpaces) > 0 { + return predictedSpaces + } + + return spaces +} + +// getTopicTypeFromPostType converts PostType to otvet topic_type +// topic_type: 2 = question (opinion), other values may be used for other types +func getTopicTypeFromPostType(postType model.PostType) int { + switch postType { + case model.OpinionPostType: + return 2 // question + case model.KnowledgePostType: + return 2 // question (can be adjusted if needed) + case model.HistoryPostType: + return 2 // question (can be adjusted if needed) + default: + return 2 // default to question + } +} + +// getDefaultSpaces returns default spaces for otvet posts +// TODO: move to config or get from request +func getDefaultSpaces() []otvet.Space { + // Default space - can be configured later + return []otvet.Space{ + { + ID: 501, // Example space ID from the response + IsPrime: true, + }, + } +} diff --git a/internal/delivery_http/posts/posts_command_consumer/handler.go b/internal/delivery_http/posts/posts_command_consumer/handler.go index dd021d9..e34f990 100644 --- a/internal/delivery_http/posts/posts_command_consumer/handler.go +++ b/internal/delivery_http/posts/posts_command_consumer/handler.go @@ -18,7 +18,8 @@ func (p *PostsCommandConsumer) HandleCommands() error { for message := range p.consumer.Start(ctx) { var env kafkaConfig.Envelope if err := jsoniter.Unmarshal(message.Msg.Value, &env); err != nil { - fmt.Printf("%s: %v\n", constants.ErrUnmarshal, err) + p.log.Error(err, false, constants.ErrUnmarshal.Error()) + _ = message.Ack(ctx) continue } @@ -32,19 +33,39 @@ func (p *PostsCommandConsumer) HandleCommands() error { err = p.handleCreateCommand(ctx, message, env.Payload) case posts.CmdSeen: err = p.handleSeenCommand(ctx, message, env.Payload) - + case posts.CmdPublish: + err = p.handlePublishCommand(ctx, message, env.Payload) default: err = errors.Errorf("unknown command received: %s", env.Command) } if err != nil { - fmt.Printf("failed to handle command '%s': %v\n", env.Command, err) + p.log.Error(err, false, fmt.Sprintf("failed to handle command '%s'", env.Command)) } } return nil } +func (p *PostsCommandConsumer) handleSeenCommand(ctx context.Context, message kafkaConfig.CommittableMessage, payload []byte) error { + err := p.SeenPosts(ctx, payload) + if err != nil { + return sendErrReply(ctx, message, err) + } + + resp := posts.KafkaResponse{} + rawResp, err := jsoniter.Marshal(resp) + if err != nil { + return errors.Wrap(err, constants.MarshalMsg) + } + + if err := message.Reply(ctx, rawResp, true); err != nil { + return errors.Wrap(err, failedToSendReplyMsg) + } + + return nil +} + func (p *PostsCommandConsumer) handleUpdateCommand(ctx context.Context, message kafkaConfig.CommittableMessage, payload []byte) error { post, err := p.UpdatePost(ctx, payload) if err != nil { @@ -95,34 +116,45 @@ func (p *PostsCommandConsumer) handleCreateCommand(ctx context.Context, message return errors.Wrap(err, "failed to ACK posts creation") } - err = p.CreatePost(ctx, postsMapping, req) - if err != nil { - // TODO: LOG - fmt.Printf("failed to create post: %s", err.Error()) - - return message.Nack(ctx, err) - } - if err = message.Ack(ctx); err != nil { - return errors.Wrap(err, "failed to ACK posts creation") + return errors.Wrap(err, "failed to commit offset") } + // Запускаем генерацию в фоне + go func() { + bgCtx := context.Background() + if err := p.CreatePost(bgCtx, postsMapping, req); err != nil { + p.log.Error(err, false, fmt.Sprintf("Async post generation failed for GroupID %s", req.GroupID)) + } else { + p.log.Info(fmt.Sprintf("Async post generation finished for GroupID %s", req.GroupID)) + } + }() + return nil } -func (p *PostsCommandConsumer) handleSeenCommand(ctx context.Context, message kafkaConfig.CommittableMessage, payload []byte) error { - err := p.SeenPosts(ctx, payload) +func (p *PostsCommandConsumer) handlePublishCommand(ctx context.Context, message kafkaConfig.CommittableMessage, payload []byte) error { + var req posts.KafkaPublishPostRequest + err := jsoniter.Unmarshal(payload, &req) if err != nil { - return sendErrReply(ctx, message, err) + return sendErrReply(ctx, message, errors.Wrap(err, "failed to unmarshal")) } - resp := posts.KafkaResponse{} - rawResp, err := jsoniter.Marshal(resp) + if p.queue == nil { + return sendErrReply(ctx, message, errors.New("queue not available")) + } + + err = p.queue.ApprovePost(req.PostID) + if err != nil { + return sendErrReply(ctx, message, errors.Wrap(err, "failed to approve post")) + } + + resp, err := jsoniter.Marshal(posts.KafkaResponse{}) if err != nil { return errors.Wrap(err, constants.MarshalMsg) } - if err := message.Reply(ctx, rawResp, true); err != nil { + if err := message.Reply(ctx, resp, true); err != nil { return errors.Wrap(err, failedToSendReplyMsg) } diff --git a/internal/delivery_http/posts/posts_command_consumer/init.go b/internal/delivery_http/posts/posts_command_consumer/init.go index a3f05ac..df762dd 100644 --- a/internal/delivery_http/posts/posts_command_consumer/init.go +++ b/internal/delivery_http/posts/posts_command_consumer/init.go @@ -6,7 +6,10 @@ import ( "github.com/google/uuid" postssgen "github.com/goriiin/kotyari-bots_backend/api/protos/posts/gen" kafkaConfig "github.com/goriiin/kotyari-bots_backend/internal/kafka" + "github.com/goriiin/kotyari-bots_backend/internal/logger" "github.com/goriiin/kotyari-bots_backend/internal/model" + "github.com/goriiin/kotyari-bots_backend/pkg/otvet" + "github.com/goriiin/kotyari-bots_backend/pkg/posting_queue" "google.golang.org/grpc" ) @@ -35,12 +38,29 @@ type judge interface { SelectBest(ctx context.Context, userPrompt, profilePrompt, botPrompt string, candidates []model.Candidate) (model.Candidate, error) } +type otvetClient interface { + CreatePost(ctx context.Context, req *otvet.CreatePostRequest) (*otvet.CreatePostResponse, error) + CreatePostSimple(ctx context.Context, title string, contentText string, topicType int, spaces []otvet.Space) (*otvet.CreatePostResponse, error) + PredictTagsSpaces(ctx context.Context, text string) (*otvet.PredictTagsSpacesResponse, error) +} + +type postingQueue interface { + Enqueue(post *model.Post, candidate model.Candidate, req posting_queue.PostRequest) *posting_queue.QueuedPost + ApprovePost(postID uuid.UUID) error + GetPostByID(postID uuid.UUID) (*posting_queue.QueuedPost, error) + StartProcessing(ctx context.Context, publishFunc func(ctx context.Context, account *posting_queue.Account, post *posting_queue.QueuedPost) error) + Stop() +} + type PostsCommandConsumer struct { - consumer consumer - repo repo - getter postsGetter - rewriter rewriter - judge judge + consumer consumer + repo repo + getter postsGetter + rewriter rewriter + judge judge + otvetClient otvetClient + queue postingQueue + log *logger.Logger } func NewPostsCommandConsumer( @@ -49,12 +69,18 @@ func NewPostsCommandConsumer( getter postsGetter, rewriter rewriter, judge judge, + otvetClient otvetClient, + queue postingQueue, + log *logger.Logger, ) *PostsCommandConsumer { return &PostsCommandConsumer{ - consumer: consumer, - repo: repo, - getter: getter, - rewriter: rewriter, - judge: judge, + consumer: consumer, + repo: repo, + getter: getter, + rewriter: rewriter, + judge: judge, + otvetClient: otvetClient, + queue: queue, + log: log, } } diff --git a/internal/delivery_http/posts/posts_command_producer/create_post.go b/internal/delivery_http/posts/posts_command_producer/create_post.go index 192afae..d7ca0bc 100644 --- a/internal/delivery_http/posts/posts_command_producer/create_post.go +++ b/internal/delivery_http/posts/posts_command_producer/create_post.go @@ -35,33 +35,6 @@ func (p *PostsCommandHandler) CreatePost(ctx context.Context, req *gen.PostInput fmt.Printf("profiles batch: %+v\n", idsString) - // mockedBot := struct { - // Id uuid.UUID - // BotPrompt string - // BotName string - // }{ - // req.BotId, - // "Промт бота", - // "Крутой бот", - // } - // - // mockedProfiles := []struct { - // Id uuid.UUID - // ProfilePrompt string - // ProfileName string - // }{ - // { - // uuid.New(), - // "Крутой промт профиля", - // "Профиль 1", - // }, - // { - // uuid.New(), - // "Супер-пупер промт", - // "Профиль 2", - // }, - // } - postProfiles := make([]posts.CreatePostProfiles, 0, len(idsString)) for _, profile := range profilesBatch.Profiles { profileID, _ := uuid.Parse(profile.Id) @@ -75,6 +48,7 @@ func (p *PostsCommandHandler) CreatePost(ctx context.Context, req *gen.PostInput groupID := uuid.New() botID, _ := uuid.Parse(bot.Id) createPostRequest := posts.KafkaCreatePostRequest{ + PostID: uuid.New(), GroupID: groupID, BotID: botID, BotName: bot.BotName, @@ -83,6 +57,8 @@ func (p *PostsCommandHandler) CreatePost(ctx context.Context, req *gen.PostInput Profiles: postProfiles, Platform: model.PlatformType(req.Platform), PostType: model.PostType(req.PostType.Value), + // pass moderation flag from bot + ModerationRequired: bot.ModerationRequired, } fmt.Printf("%+v\n", createPostRequest) diff --git a/internal/delivery_http/posts/posts_command_producer/publish_post.go b/internal/delivery_http/posts/posts_command_producer/publish_post.go new file mode 100644 index 0000000..39cc509 --- /dev/null +++ b/internal/delivery_http/posts/posts_command_producer/publish_post.go @@ -0,0 +1,62 @@ +package posts_command_producer + +import ( + "context" + "net/http" + "time" + + "github.com/goriiin/kotyari-bots_backend/internal/delivery_http/posts" + gen "github.com/goriiin/kotyari-bots_backend/internal/gen/posts/posts_command" + jsoniter "github.com/json-iterator/go" +) + +func (p *PostsCommandHandler) PublishPost(ctx context.Context, req *gen.PublishPostRequest, params gen.PublishPostParams) (gen.PublishPostRes, error) { + if !req.Approved { + return &gen.PublishPostBadRequest{ + ErrorCode: http.StatusBadRequest, + Message: "post must be approved to publish", + }, nil + } + + publishRequest := posts.KafkaPublishPostRequest{ + PostID: params.PostId, + Approved: req.Approved, + } + + rawReq, err := jsoniter.Marshal(publishRequest) + if err != nil { + return &gen.PublishPostInternalServerError{ + ErrorCode: http.StatusInternalServerError, + Message: err.Error(), + }, nil + } + + rawResp, err := p.producer.Request(ctx, posts.PayloadToEnvelope(posts.CmdPublish, params.PostId.String(), rawReq), 5*time.Second) + if err != nil { + return &gen.PublishPostInternalServerError{ + ErrorCode: http.StatusInternalServerError, + Message: err.Error(), + }, nil + } + + var resp posts.KafkaResponse + err = jsoniter.Unmarshal(rawResp, &resp) + if err != nil { + return &gen.PublishPostInternalServerError{ + ErrorCode: http.StatusInternalServerError, + Message: err.Error(), + }, nil + } + + if resp.Error != "" { + return &gen.PublishPostInternalServerError{ + ErrorCode: http.StatusInternalServerError, + Message: resp.Error, + }, nil + } + + return &gen.PublishPostResponse{ + Success: true, + Message: gen.NewOptString("Post approved for publishing"), + }, nil +} diff --git a/internal/delivery_http/posts/posts_command_producer/update_post.go b/internal/delivery_http/posts/posts_command_producer/update_post.go index 31a3614..afa281a 100644 --- a/internal/delivery_http/posts/posts_command_producer/update_post.go +++ b/internal/delivery_http/posts/posts_command_producer/update_post.go @@ -49,7 +49,7 @@ func (p *PostsCommandHandler) UpdatePostById(ctx context.Context, req *gen.PostU case strings.Contains(resp.Error, constants.InternalMsg): return &gen.UpdatePostByIdInternalServerError{ ErrorCode: http.StatusInternalServerError, - Message: constants.InternalMsg, + Message: resp.Error, }, nil case strings.Contains(resp.Error, constants.NotFoundMsg): diff --git a/internal/delivery_http/posts/posts_query/get_by_id.go b/internal/delivery_http/posts/posts_query/get_by_id.go index 0b0b09b..059475d 100644 --- a/internal/delivery_http/posts/posts_query/get_by_id.go +++ b/internal/delivery_http/posts/posts_query/get_by_id.go @@ -15,7 +15,6 @@ func (p *PostsQueryHandler) GetPostById(ctx context.Context, params gen.GetPostB // Пока возвращается пост без категорий post, err := p.repo.GetByID(ctx, params.PostId) if err != nil { - if strings.Contains(err.Error(), constants.NotFoundMsg) { return &gen.GetPostByIdNotFound{ErrorCode: http.StatusNotFound, Message: "post not found"}, nil } diff --git a/internal/gen/posts/posts_command/oas_client_gen.go b/internal/gen/posts/posts_command/oas_client_gen.go index 3405df4..e17df73 100644 --- a/internal/gen/posts/posts_command/oas_client_gen.go +++ b/internal/gen/posts/posts_command/oas_client_gen.go @@ -39,6 +39,12 @@ type Invoker interface { // // DELETE /api/v1/posts/{postId} DeletePostById(ctx context.Context, params DeletePostByIdParams) (DeletePostByIdRes, error) + // PublishPost invokes publishPost operation. + // + // Опубликовать пост (для модерации). + // + // POST /api/v1/posts/{postId}/publish + PublishPost(ctx context.Context, request *PublishPostRequest, params PublishPostParams) (PublishPostRes, error) // SeenPosts invokes seenPosts operation. // // Обновить статус постов, которые пользователь уже @@ -264,6 +270,101 @@ func (c *Client) sendDeletePostById(ctx context.Context, params DeletePostByIdPa return result, nil } +// PublishPost invokes publishPost operation. +// +// Опубликовать пост (для модерации). +// +// POST /api/v1/posts/{postId}/publish +func (c *Client) PublishPost(ctx context.Context, request *PublishPostRequest, params PublishPostParams) (PublishPostRes, error) { + res, err := c.sendPublishPost(ctx, request, params) + return res, err +} + +func (c *Client) sendPublishPost(ctx context.Context, request *PublishPostRequest, params PublishPostParams) (res PublishPostRes, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("publishPost"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.URLTemplateKey.String("/api/v1/posts/{postId}/publish"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, PublishPostOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [3]string + pathParts[0] = "/api/v1/posts/" + { + // Encode "postId" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "postId", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.UUIDToString(params.PostId)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + pathParts[2] = "/publish" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodePublishPostRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + defer resp.Body.Close() + + stage = "DecodeResponse" + result, err := decodePublishPostResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // SeenPosts invokes seenPosts operation. // // Обновить статус постов, которые пользователь уже diff --git a/internal/gen/posts/posts_command/oas_handlers_gen.go b/internal/gen/posts/posts_command/oas_handlers_gen.go index 2b3b174..663dd4f 100644 --- a/internal/gen/posts/posts_command/oas_handlers_gen.go +++ b/internal/gen/posts/posts_command/oas_handlers_gen.go @@ -311,6 +311,162 @@ func (s *Server) handleDeletePostByIdRequest(args [1]string, argsEscaped bool, w } } +// handlePublishPostRequest handles publishPost operation. +// +// Опубликовать пост (для модерации). +// +// POST /api/v1/posts/{postId}/publish +func (s *Server) handlePublishPostRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("publishPost"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/api/v1/posts/{postId}/publish"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), PublishPostOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: PublishPostOperation, + ID: "publishPost", + } + ) + params, err := decodePublishPostParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + request, rawBody, close, err := s.decodePublishPostRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response PublishPostRes + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: PublishPostOperation, + OperationSummary: "Опубликовать пост (для модерации)", + OperationID: "publishPost", + Body: request, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "postId", + In: "path", + }: params.PostId, + }, + Raw: r, + } + + type ( + Request = *PublishPostRequest + Params = PublishPostParams + Response = PublishPostRes + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackPublishPostParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.PublishPost(ctx, request, params) + return response, err + }, + ) + } else { + response, err = s.h.PublishPost(ctx, request, params) + } + if err != nil { + defer recordError("Internal", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + if err := encodePublishPostResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleSeenPostsRequest handles seenPosts operation. // // Обновить статус постов, которые пользователь уже diff --git a/internal/gen/posts/posts_command/oas_interfaces_gen.go b/internal/gen/posts/posts_command/oas_interfaces_gen.go index f8e132a..a64a87a 100644 --- a/internal/gen/posts/posts_command/oas_interfaces_gen.go +++ b/internal/gen/posts/posts_command/oas_interfaces_gen.go @@ -9,6 +9,10 @@ type DeletePostByIdRes interface { deletePostByIdRes() } +type PublishPostRes interface { + publishPostRes() +} + type SeenPostsRes interface { seenPostsRes() } diff --git a/internal/gen/posts/posts_command/oas_json_gen.go b/internal/gen/posts/posts_command/oas_json_gen.go index 012eb5e..3cfc226 100644 --- a/internal/gen/posts/posts_command/oas_json_gen.go +++ b/internal/gen/posts/posts_command/oas_json_gen.go @@ -730,6 +730,41 @@ func (s *OptNilUUIDArray) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes string as json. +func (o OptString) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes string from json. +func (o *OptString) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptString to nil") + } + o.Set = true + v, err := d.Str() + if err != nil { + return err + } + o.Value = string(v) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptString) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptString) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *Post) Encode(e *jx.Encoder) { e.ObjStart() @@ -777,6 +812,10 @@ func (s *Post) encodeFields(e *jx.Encoder) { s.PostType.Encode(e) } } + { + e.FieldStart("task") + e.Str(s.Task) + } { e.FieldStart("title") e.Str(s.Title) @@ -805,7 +844,7 @@ func (s *Post) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfPost = [14]string{ +var jsonFieldsNameOfPost = [15]string{ 0: "id", 1: "otvetiId", 2: "groupId", @@ -815,11 +854,12 @@ var jsonFieldsNameOfPost = [14]string{ 6: "profileName", 7: "platform", 8: "postType", - 9: "title", - 10: "text", - 11: "categories", - 12: "createdAt", - 13: "updatedAt", + 9: "task", + 10: "title", + 11: "text", + 12: "categories", + 13: "createdAt", + 14: "updatedAt", } // Decode decodes Post from json. @@ -935,8 +975,20 @@ func (s *Post) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"postType\"") } - case "title": + case "task": requiredBitSet[1] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Task = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"task\"") + } + case "title": + requiredBitSet[1] |= 1 << 2 if err := func() error { v, err := d.Str() s.Title = string(v) @@ -948,7 +1000,7 @@ func (s *Post) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"title\"") } case "text": - requiredBitSet[1] |= 1 << 2 + requiredBitSet[1] |= 1 << 3 if err := func() error { v, err := d.Str() s.Text = string(v) @@ -977,7 +1029,7 @@ func (s *Post) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"categories\"") } case "createdAt": - requiredBitSet[1] |= 1 << 4 + requiredBitSet[1] |= 1 << 5 if err := func() error { v, err := json.DecodeDateTime(d) s.CreatedAt = v @@ -989,7 +1041,7 @@ func (s *Post) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"createdAt\"") } case "updatedAt": - requiredBitSet[1] |= 1 << 5 + requiredBitSet[1] |= 1 << 6 if err := func() error { v, err := json.DecodeDateTime(d) s.UpdatedAt = v @@ -1011,7 +1063,7 @@ func (s *Post) Decode(d *jx.Decoder) error { var failures []validate.FieldError for i, mask := range [2]uint8{ 0b11111111, - 0b00110110, + 0b01101110, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. @@ -1723,6 +1775,367 @@ func (s *PostsSeenRequest) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes PublishPostBadRequest as json. +func (s *PublishPostBadRequest) Encode(e *jx.Encoder) { + unwrapped := (*Error)(s) + + unwrapped.Encode(e) +} + +// Decode decodes PublishPostBadRequest from json. +func (s *PublishPostBadRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PublishPostBadRequest to nil") + } + var unwrapped Error + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = PublishPostBadRequest(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PublishPostBadRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PublishPostBadRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PublishPostInternalServerError as json. +func (s *PublishPostInternalServerError) Encode(e *jx.Encoder) { + unwrapped := (*Error)(s) + + unwrapped.Encode(e) +} + +// Decode decodes PublishPostInternalServerError from json. +func (s *PublishPostInternalServerError) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PublishPostInternalServerError to nil") + } + var unwrapped Error + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = PublishPostInternalServerError(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PublishPostInternalServerError) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PublishPostInternalServerError) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PublishPostNotFound as json. +func (s *PublishPostNotFound) Encode(e *jx.Encoder) { + unwrapped := (*Error)(s) + + unwrapped.Encode(e) +} + +// Decode decodes PublishPostNotFound from json. +func (s *PublishPostNotFound) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PublishPostNotFound to nil") + } + var unwrapped Error + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = PublishPostNotFound(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PublishPostNotFound) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PublishPostNotFound) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PublishPostRequest) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PublishPostRequest) encodeFields(e *jx.Encoder) { + { + e.FieldStart("approved") + e.Bool(s.Approved) + } +} + +var jsonFieldsNameOfPublishPostRequest = [1]string{ + 0: "approved", +} + +// Decode decodes PublishPostRequest from json. +func (s *PublishPostRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PublishPostRequest to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "approved": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Bool() + s.Approved = bool(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"approved\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PublishPostRequest") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPublishPostRequest) { + name = jsonFieldsNameOfPublishPostRequest[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PublishPostRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PublishPostRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PublishPostResponse) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PublishPostResponse) encodeFields(e *jx.Encoder) { + { + e.FieldStart("success") + e.Bool(s.Success) + } + { + if s.Message.Set { + e.FieldStart("message") + s.Message.Encode(e) + } + } +} + +var jsonFieldsNameOfPublishPostResponse = [2]string{ + 0: "success", + 1: "message", +} + +// Decode decodes PublishPostResponse from json. +func (s *PublishPostResponse) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PublishPostResponse to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "success": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Bool() + s.Success = bool(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"success\"") + } + case "message": + if err := func() error { + s.Message.Reset() + if err := s.Message.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"message\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PublishPostResponse") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPublishPostResponse) { + name = jsonFieldsNameOfPublishPostResponse[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PublishPostResponse) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PublishPostResponse) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PublishPostUnauthorized as json. +func (s *PublishPostUnauthorized) Encode(e *jx.Encoder) { + unwrapped := (*Error)(s) + + unwrapped.Encode(e) +} + +// Decode decodes PublishPostUnauthorized from json. +func (s *PublishPostUnauthorized) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PublishPostUnauthorized to nil") + } + var unwrapped Error + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = PublishPostUnauthorized(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PublishPostUnauthorized) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PublishPostUnauthorized) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes SeenPostsInternalServerError as json. func (s *SeenPostsInternalServerError) Encode(e *jx.Encoder) { unwrapped := (*Error)(s) diff --git a/internal/gen/posts/posts_command/oas_operations_gen.go b/internal/gen/posts/posts_command/oas_operations_gen.go index 83c72d8..74bdcc2 100644 --- a/internal/gen/posts/posts_command/oas_operations_gen.go +++ b/internal/gen/posts/posts_command/oas_operations_gen.go @@ -8,6 +8,7 @@ type OperationName = string const ( CreatePostOperation OperationName = "CreatePost" DeletePostByIdOperation OperationName = "DeletePostById" + PublishPostOperation OperationName = "PublishPost" SeenPostsOperation OperationName = "SeenPosts" UpdatePostByIdOperation OperationName = "UpdatePostById" ) diff --git a/internal/gen/posts/posts_command/oas_parameters_gen.go b/internal/gen/posts/posts_command/oas_parameters_gen.go index 7e9f668..aea8c07 100644 --- a/internal/gen/posts/posts_command/oas_parameters_gen.go +++ b/internal/gen/posts/posts_command/oas_parameters_gen.go @@ -81,6 +81,72 @@ func decodeDeletePostByIdParams(args [1]string, argsEscaped bool, r *http.Reques return params, nil } +// PublishPostParams is parameters of publishPost operation. +type PublishPostParams struct { + // Уникальный идентификатор поста. + PostId uuid.UUID +} + +func unpackPublishPostParams(packed middleware.Parameters) (params PublishPostParams) { + { + key := middleware.ParameterKey{ + Name: "postId", + In: "path", + } + params.PostId = packed[key].(uuid.UUID) + } + return params +} + +func decodePublishPostParams(args [1]string, argsEscaped bool, r *http.Request) (params PublishPostParams, _ error) { + // Decode path: postId. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "postId", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToUUID(val) + if err != nil { + return err + } + + params.PostId = c + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "postId", + In: "path", + Err: err, + } + } + return params, nil +} + // UpdatePostByIdParams is parameters of updatePostById operation. type UpdatePostByIdParams struct { // Уникальный идентификатор поста. diff --git a/internal/gen/posts/posts_command/oas_request_decoders_gen.go b/internal/gen/posts/posts_command/oas_request_decoders_gen.go index 1844460..5a65c02 100644 --- a/internal/gen/posts/posts_command/oas_request_decoders_gen.go +++ b/internal/gen/posts/posts_command/oas_request_decoders_gen.go @@ -93,6 +93,77 @@ func (s *Server) decodeCreatePostRequest(r *http.Request) ( } } +func (s *Server) decodePublishPostRequest(r *http.Request) ( + req *PublishPostRequest, + rawBody []byte, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, rawBody, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + defer func() { + _ = r.Body.Close() + }() + if err != nil { + return req, rawBody, close, err + } + + // Reset the body to allow for downstream reading. + r.Body = io.NopCloser(bytes.NewBuffer(buf)) + + if len(buf) == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + + rawBody = append(rawBody, buf...) + d := jx.DecodeBytes(buf) + + var request PublishPostRequest + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, rawBody, close, err + } + return &request, rawBody, close, nil + default: + return req, rawBody, close, validate.InvalidContentType(ct) + } +} + func (s *Server) decodeSeenPostsRequest(r *http.Request) ( req *PostsSeenRequest, rawBody []byte, diff --git a/internal/gen/posts/posts_command/oas_request_encoders_gen.go b/internal/gen/posts/posts_command/oas_request_encoders_gen.go index ac4c67a..f1ee57a 100644 --- a/internal/gen/posts/posts_command/oas_request_encoders_gen.go +++ b/internal/gen/posts/posts_command/oas_request_encoders_gen.go @@ -24,6 +24,20 @@ func encodeCreatePostRequest( return nil } +func encodePublishPostRequest( + req *PublishPostRequest, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} + func encodeSeenPostsRequest( req *PostsSeenRequest, r *http.Request, diff --git a/internal/gen/posts/posts_command/oas_response_decoders_gen.go b/internal/gen/posts/posts_command/oas_response_decoders_gen.go index 2fdaab6..cc6c5d2 100644 --- a/internal/gen/posts/posts_command/oas_response_decoders_gen.go +++ b/internal/gen/posts/posts_command/oas_response_decoders_gen.go @@ -273,6 +273,187 @@ func decodeDeletePostByIdResponse(resp *http.Response) (res DeletePostByIdRes, _ return res, validate.UnexpectedStatusCodeWithResponse(resp) } +func decodePublishPostResponse(resp *http.Response) (res PublishPostRes, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PublishPostResponse + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 400: + // Code 400. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PublishPostBadRequest + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 401: + // Code 401. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PublishPostUnauthorized + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 404: + // Code 404. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PublishPostNotFound + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 500: + // Code 500. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PublishPostInternalServerError + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + return res, validate.UnexpectedStatusCodeWithResponse(resp) +} + func decodeSeenPostsResponse(resp *http.Response) (res SeenPostsRes, _ error) { switch resp.StatusCode { case 204: diff --git a/internal/gen/posts/posts_command/oas_response_encoders_gen.go b/internal/gen/posts/posts_command/oas_response_encoders_gen.go index d47f3d1..276222b 100644 --- a/internal/gen/posts/posts_command/oas_response_encoders_gen.go +++ b/internal/gen/posts/posts_command/oas_response_encoders_gen.go @@ -122,6 +122,78 @@ func encodeDeletePostByIdResponse(response DeletePostByIdRes, w http.ResponseWri } } +func encodePublishPostResponse(response PublishPostRes, w http.ResponseWriter, span trace.Span) error { + switch response := response.(type) { + case *PublishPostResponse: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *PublishPostBadRequest: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(400) + span.SetStatus(codes.Error, http.StatusText(400)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *PublishPostUnauthorized: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(401) + span.SetStatus(codes.Error, http.StatusText(401)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *PublishPostNotFound: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(404) + span.SetStatus(codes.Error, http.StatusText(404)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *PublishPostInternalServerError: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(500) + span.SetStatus(codes.Error, http.StatusText(500)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + default: + return errors.Errorf("unexpected response type: %T", response) + } +} + func encodeSeenPostsResponse(response SeenPostsRes, w http.ResponseWriter, span trace.Span) error { switch response := response.(type) { case *NoContent: diff --git a/internal/gen/posts/posts_command/oas_router_gen.go b/internal/gen/posts/posts_command/oas_router_gen.go index b43abe2..b0a3418 100644 --- a/internal/gen/posts/posts_command/oas_router_gen.go +++ b/internal/gen/posts/posts_command/oas_router_gen.go @@ -103,16 +103,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { elem = origElem } // Param: "postId" - // Leaf parameter, slashes are prohibited + // Match until "/" idx := strings.IndexByte(elem, '/') - if idx >= 0 { - break + if idx < 0 { + idx = len(elem) } - args[0] = elem - elem = "" + args[0] = elem[:idx] + elem = elem[idx:] if len(elem) == 0 { - // Leaf node. switch r.Method { case "DELETE": s.handleDeletePostByIdRequest([1]string{ @@ -128,6 +127,30 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + switch elem[0] { + case '/': // Prefix: "/publish" + + if l := len("/publish"); len(elem) >= l && elem[0:l] == "/publish" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handlePublishPostRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "POST") + } + + return + } + + } } @@ -273,16 +296,15 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { elem = origElem } // Param: "postId" - // Leaf parameter, slashes are prohibited + // Match until "/" idx := strings.IndexByte(elem, '/') - if idx >= 0 { - break + if idx < 0 { + idx = len(elem) } - args[0] = elem - elem = "" + args[0] = elem[:idx] + elem = elem[idx:] if len(elem) == 0 { - // Leaf node. switch method { case "DELETE": r.name = DeletePostByIdOperation @@ -304,6 +326,32 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { return } } + switch elem[0] { + case '/': // Prefix: "/publish" + + if l := len("/publish"); len(elem) >= l && elem[0:l] == "/publish" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = PublishPostOperation + r.summary = "Опубликовать пост (для модерации)" + r.operationID = "publishPost" + r.pathPattern = "/api/v1/posts/{postId}/publish" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + + } } diff --git a/internal/gen/posts/posts_command/oas_schemas_gen.go b/internal/gen/posts/posts_command/oas_schemas_gen.go index eefc2be..0582e44 100644 --- a/internal/gen/posts/posts_command/oas_schemas_gen.go +++ b/internal/gen/posts/posts_command/oas_schemas_gen.go @@ -337,6 +337,52 @@ func (o OptNilUUIDArray) Or(d []uuid.UUID) []uuid.UUID { return d } +// NewOptString returns new OptString with value set to v. +func NewOptString(v string) OptString { + return OptString{ + Value: v, + Set: true, + } +} + +// OptString is optional string. +type OptString struct { + Value string + Set bool +} + +// IsSet returns true if OptString was set. +func (o OptString) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptString) Reset() { + var v string + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptString) SetTo(v string) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptString) Get() (v string, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptString) Or(d string) string { + if v, ok := o.Get(); ok { + return v + } + return d +} + // Пост. // Ref: #/Post type Post struct { @@ -358,6 +404,8 @@ type Post struct { Platform PostPlatform `json:"platform"` // Тип поста. PostType OptNilPostPostType `json:"postType"` + // Задание на генерацию от пользователя. + Task string `json:"task"` // Название поста. Title string `json:"title"` // Текстовое содержимое поста. @@ -413,6 +461,11 @@ func (s *Post) GetPostType() OptNilPostPostType { return s.PostType } +// GetTask returns the value of Task. +func (s *Post) GetTask() string { + return s.Task +} + // GetTitle returns the value of Title. func (s *Post) GetTitle() string { return s.Title @@ -483,6 +536,11 @@ func (s *Post) SetPostType(val OptNilPostPostType) { s.PostType = val } +// SetTask sets the value of Task. +func (s *Post) SetTask(val string) { + s.Task = val +} + // SetTitle sets the value of Title. func (s *Post) SetTitle(val string) { s.Title = val @@ -821,6 +879,70 @@ func (s *PostsSeenRequest) SetSeen(val []uuid.UUID) { s.Seen = val } +type PublishPostBadRequest Error + +func (*PublishPostBadRequest) publishPostRes() {} + +type PublishPostInternalServerError Error + +func (*PublishPostInternalServerError) publishPostRes() {} + +type PublishPostNotFound Error + +func (*PublishPostNotFound) publishPostRes() {} + +// Данные для публикации поста. +// Ref: #/PublishPostRequest +type PublishPostRequest struct { + // Одобрен ли пост для публикации. + Approved bool `json:"approved"` +} + +// GetApproved returns the value of Approved. +func (s *PublishPostRequest) GetApproved() bool { + return s.Approved +} + +// SetApproved sets the value of Approved. +func (s *PublishPostRequest) SetApproved(val bool) { + s.Approved = val +} + +// Результат публикации поста. +// Ref: #/PublishPostResponse +type PublishPostResponse struct { + // Успешно ли опубликован пост. + Success bool `json:"success"` + // Сообщение о результате. + Message OptString `json:"message"` +} + +// GetSuccess returns the value of Success. +func (s *PublishPostResponse) GetSuccess() bool { + return s.Success +} + +// GetMessage returns the value of Message. +func (s *PublishPostResponse) GetMessage() OptString { + return s.Message +} + +// SetSuccess sets the value of Success. +func (s *PublishPostResponse) SetSuccess(val bool) { + s.Success = val +} + +// SetMessage sets the value of Message. +func (s *PublishPostResponse) SetMessage(val OptString) { + s.Message = val +} + +func (*PublishPostResponse) publishPostRes() {} + +type PublishPostUnauthorized Error + +func (*PublishPostUnauthorized) publishPostRes() {} + type SeenPostsInternalServerError Error func (*SeenPostsInternalServerError) seenPostsRes() {} diff --git a/internal/gen/posts/posts_command/oas_server_gen.go b/internal/gen/posts/posts_command/oas_server_gen.go index ee3441d..8145bef 100644 --- a/internal/gen/posts/posts_command/oas_server_gen.go +++ b/internal/gen/posts/posts_command/oas_server_gen.go @@ -20,6 +20,12 @@ type Handler interface { // // DELETE /api/v1/posts/{postId} DeletePostById(ctx context.Context, params DeletePostByIdParams) (DeletePostByIdRes, error) + // PublishPost implements publishPost operation. + // + // Опубликовать пост (для модерации). + // + // POST /api/v1/posts/{postId}/publish + PublishPost(ctx context.Context, req *PublishPostRequest, params PublishPostParams) (PublishPostRes, error) // SeenPosts implements seenPosts operation. // // Обновить статус постов, которые пользователь уже diff --git a/internal/gen/posts/posts_command/oas_unimplemented_gen.go b/internal/gen/posts/posts_command/oas_unimplemented_gen.go index ef47672..1ee42ee 100644 --- a/internal/gen/posts/posts_command/oas_unimplemented_gen.go +++ b/internal/gen/posts/posts_command/oas_unimplemented_gen.go @@ -31,6 +31,15 @@ func (UnimplementedHandler) DeletePostById(ctx context.Context, params DeletePos return r, ht.ErrNotImplemented } +// PublishPost implements publishPost operation. +// +// Опубликовать пост (для модерации). +// +// POST /api/v1/posts/{postId}/publish +func (UnimplementedHandler) PublishPost(ctx context.Context, req *PublishPostRequest, params PublishPostParams) (r PublishPostRes, _ error) { + return r, ht.ErrNotImplemented +} + // SeenPosts implements seenPosts operation. // // Обновить статус постов, которые пользователь уже diff --git a/internal/gen/posts/posts_query/oas_json_gen.go b/internal/gen/posts/posts_query/oas_json_gen.go index 866257d..4f9f538 100644 --- a/internal/gen/posts/posts_query/oas_json_gen.go +++ b/internal/gen/posts/posts_query/oas_json_gen.go @@ -859,6 +859,10 @@ func (s *Post) encodeFields(e *jx.Encoder) { s.PostType.Encode(e) } } + { + e.FieldStart("task") + e.Str(s.Task) + } { e.FieldStart("title") e.Str(s.Title) @@ -887,7 +891,7 @@ func (s *Post) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfPost = [14]string{ +var jsonFieldsNameOfPost = [15]string{ 0: "id", 1: "otvetiId", 2: "groupId", @@ -897,11 +901,12 @@ var jsonFieldsNameOfPost = [14]string{ 6: "profileName", 7: "platform", 8: "postType", - 9: "title", - 10: "text", - 11: "categories", - 12: "createdAt", - 13: "updatedAt", + 9: "task", + 10: "title", + 11: "text", + 12: "categories", + 13: "createdAt", + 14: "updatedAt", } // Decode decodes Post from json. @@ -1017,8 +1022,20 @@ func (s *Post) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"postType\"") } - case "title": + case "task": requiredBitSet[1] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Task = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"task\"") + } + case "title": + requiredBitSet[1] |= 1 << 2 if err := func() error { v, err := d.Str() s.Title = string(v) @@ -1030,7 +1047,7 @@ func (s *Post) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"title\"") } case "text": - requiredBitSet[1] |= 1 << 2 + requiredBitSet[1] |= 1 << 3 if err := func() error { v, err := d.Str() s.Text = string(v) @@ -1059,7 +1076,7 @@ func (s *Post) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"categories\"") } case "createdAt": - requiredBitSet[1] |= 1 << 4 + requiredBitSet[1] |= 1 << 5 if err := func() error { v, err := json.DecodeDateTime(d) s.CreatedAt = v @@ -1071,7 +1088,7 @@ func (s *Post) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"createdAt\"") } case "updatedAt": - requiredBitSet[1] |= 1 << 5 + requiredBitSet[1] |= 1 << 6 if err := func() error { v, err := json.DecodeDateTime(d) s.UpdatedAt = v @@ -1093,7 +1110,7 @@ func (s *Post) Decode(d *jx.Decoder) error { var failures []validate.FieldError for i, mask := range [2]uint8{ 0b11111111, - 0b00110110, + 0b01101110, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. diff --git a/internal/gen/posts/posts_query/oas_schemas_gen.go b/internal/gen/posts/posts_query/oas_schemas_gen.go index 8d4694d..bf6baeb 100644 --- a/internal/gen/posts/posts_query/oas_schemas_gen.go +++ b/internal/gen/posts/posts_query/oas_schemas_gen.go @@ -264,6 +264,8 @@ type Post struct { Platform PostPlatform `json:"platform"` // Тип поста. PostType OptNilPostPostType `json:"postType"` + // Задание на генерацию от пользователя. + Task string `json:"task"` // Название поста. Title string `json:"title"` // Текстовое содержимое поста. @@ -319,6 +321,11 @@ func (s *Post) GetPostType() OptNilPostPostType { return s.PostType } +// GetTask returns the value of Task. +func (s *Post) GetTask() string { + return s.Task +} + // GetTitle returns the value of Title. func (s *Post) GetTitle() string { return s.Title @@ -389,6 +396,11 @@ func (s *Post) SetPostType(val OptNilPostPostType) { s.PostType = val } +// SetTask sets the value of Task. +func (s *Post) SetTask(val string) { + s.Task = val +} + // SetTitle sets the value of Title. func (s *Post) SetTitle(val string) { s.Title = val diff --git a/internal/repo/bots/delete.go b/internal/repo/bots/delete.go index 1781374..883c739 100644 --- a/internal/repo/bots/delete.go +++ b/internal/repo/bots/delete.go @@ -7,9 +7,6 @@ import ( ) func (r *BotsRepository) Delete(ctx context.Context, id uuid.UUID) error { - _, err := r.db.Exec(ctx, `delete from bots where id=$1`, id) - if err != nil { - return err - } - return nil + _, err := r.db.Exec(ctx, `UPDATE bots SET is_deleted = true, updated_at = NOW() WHERE id=$1`, id) + return err } diff --git a/internal/repo/posts/dto.go b/internal/repo/posts/dto.go index cc39c8e..43f6e13 100644 --- a/internal/repo/posts/dto.go +++ b/internal/repo/posts/dto.go @@ -68,6 +68,7 @@ func (d PostDTO) ToModel() model.Post { ProfileName: d.ProfileName, Platform: model.PlatformType(d.Platform), Type: postType, + UserPrompt: d.UserPrompt, Title: d.Title, Text: d.Text, CreatedAt: d.CreatedAt, diff --git a/internal/repo/posts/posts_command/update_post.go b/internal/repo/posts/posts_command/update_post.go index 4bfa660..0599e43 100644 --- a/internal/repo/posts/posts_command/update_post.go +++ b/internal/repo/posts/posts_command/update_post.go @@ -15,7 +15,7 @@ func (p *PostsCommandRepo) UpdatePost(ctx context.Context, post model.Post) (mod UPDATE posts SET post_title=$1, post_text=$2, updated_at=NOW() WHERE id=$3 - RETURNING id, otveti_id, bot_id, profile_id, platform_type, post_type, post_title, post_text, created_at, updated_at + RETURNING id, otveti_id, bot_id, bot_name, profile_id, profile_name, group_id, platform_type, user_prompt, post_type, post_title, post_text, created_at, updated_at ` rows, err := p.db.Query(ctx, query, post.Title, post.Text, post.ID) @@ -31,5 +31,6 @@ func (p *PostsCommandRepo) UpdatePost(ctx context.Context, post model.Post) (mod return model.Post{}, constants.ErrInternal } + return modifiedPost.ToModel(), nil } diff --git a/nginx.conf.https b/nginx.conf.https index 918c399..008e0ea 100644 --- a/nginx.conf.https +++ b/nginx.conf.https @@ -6,23 +6,35 @@ http { include mime.types; default_type application/octet-stream; - # ... твои upstream (bots, profiles, posts ...) ... - upstream bots_service { server bots_go:8001; } - upstream profiles_service { server profiles_go:8003; } - upstream posts_command_service { server posts_command_prod_go:8088; } - upstream posts_query_service { server posts_query_go:8089; } + # 1. Резолвер для динамического upstream + resolver 127.0.0.11 valid=10s; - map $request_method $posts_upstream { - GET posts_query_service; - HEAD posts_query_service; - default posts_command_service; + # 2. Карта маршрутизации для бэкенда постов + map $request_method $posts_target_host { + GET "posts_query_go:8089"; + HEAD "posts_query_go:8089"; + default "posts_command_prod_go:8088"; } + # 3. Upstream для Nuxt SSR приложения + upstream nuxt_app { + # host.docker.internal теперь указывает на ваш хост-сервер (где запущен Nuxt) + server host.docker.internal:3000; + keepalive 64; + } + + # Сервер для редиректа с HTTP на HTTPS server { listen 80; server_name writehub.space www.writehub.space; - location /.well-known/acme-challenge/ { root /var/www/certbot; } - location / { return 301 https://$host$request_uri; } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } } server { @@ -32,30 +44,25 @@ http { ssl_certificate /etc/letsencrypt/live/writehub.space/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/writehub.space/privkey.pem; - # SSL настройки (как были)... - - # === FRONTEND CONFIGURATION === - root /var/www/frontend; - index index.html; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; - # Главная точка входа location / { - # Для SPA (Single Page Application). - # Если файл не найден, отдать index.html (Nuxt подхватит роутинг) - try_files $uri $uri/ /index.html; - } - - # Кеширование статики (_nuxt, изображения, шрифты) - location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|svg|ttf|eot)$ { - expires 6M; - access_log off; - add_header Cache-Control "public"; + proxy_pass http://nuxt_app; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } - # ============================== - # API (Backend) - без изменений location /api/v1/bots { - proxy_pass http://bots_service; + set $upstream_bots "http://bots_go:8001"; + proxy_pass $upstream_bots; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -63,7 +70,8 @@ http { } location /api/v1/profiles { - proxy_pass http://profiles_service; + set $upstream_profiles "http://profiles_go:8003"; + proxy_pass $upstream_profiles; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -71,11 +79,13 @@ http { } location /api/v1/posts { - proxy_pass http://$posts_upstream; + set $upstream_posts "http://$posts_target_host"; + proxy_pass $upstream_posts; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } -} \ No newline at end of file +} + diff --git a/pkg/otvet/config.go b/pkg/otvet/config.go new file mode 100644 index 0000000..16364f7 --- /dev/null +++ b/pkg/otvet/config.go @@ -0,0 +1,28 @@ +package otvet + +import ( + "fmt" + "time" + + "github.com/goriiin/kotyari-bots_backend/pkg/config" +) + +const OtvetBaseURL = "https://otvet.mail.ru" + +type OtvetClientConfig struct { + config.ConfigBase + AuthToken string `mapstructure:"auth_token" env:"OTVET_AUTH_TOKEN"` + Timeout time.Duration `mapstructure:"request_timeout"` +} + +func (o *OtvetClientConfig) Validate() error { + if o.AuthToken == "" { + return fmt.Errorf("missing auth token") + } + + if o.Timeout == 0 { + o.Timeout = 30 * time.Second + } + + return nil +} diff --git a/pkg/otvet/create_post.go b/pkg/otvet/create_post.go new file mode 100644 index 0000000..46b89e4 --- /dev/null +++ b/pkg/otvet/create_post.go @@ -0,0 +1,186 @@ +package otvet + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "github.com/go-faster/errors" +) + +const ( + createPostEndpoint = "/api/topic/question" + predictTagsSpacesEndpoint = "/api/tags_spaces/predict" +) + +// CreatePost creates a new post/question on otvet.mail.ru +func (c *OtvetClient) CreatePost(ctx context.Context, req *CreatePostRequest) (*CreatePostResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal request") + } + + url := c.baseURL + createPostEndpoint + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body)) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + + // Set headers + httpReq.Header.Set("Content-Type", "application/json") + + // Set authentication cookie + httpReq.AddCookie(&http.Cookie{ + Name: "Auth-Token", + Value: c.config.AuthToken, + }) + + // Perform request + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, errors.Wrap(err, "failed to perform request") + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + // Log error if needed + _ = closeErr + } + }() + + // Read response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + + // Check status code + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, errors.Errorf("otvet.mail.ru returned non-2xx response status: %d, body: %s", resp.StatusCode, string(respBody)) + } + + // Parse response + var createResp CreatePostResponse + if err := json.Unmarshal(respBody, &createResp); err != nil { + // If response is not JSON or doesn't match expected structure, return raw response info + return nil, errors.Wrapf(err, "failed to unmarshal response, status: %d, body: %s", resp.StatusCode, string(respBody)) + } + + return &createResp, nil +} + +// TextContentNode represents a simple text content node +type TextContentNode struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// CreatePostSimple is a helper function to create a simple post with minimal required fields +func (c *OtvetClient) CreatePostSimple(ctx context.Context, title string, contentText string, topicType int, spaces []Space) (*CreatePostResponse, error) { + req := &CreatePostRequest{ + Title: title, + TopicType: topicType, + Version: 0, + VisibleTo: 0, + Content: &Content{ + Type: "doc", + Content: []ContentNode{ + { + Type: "paragraph", + Content: []interface{}{ + TextContentNode{ + Type: "text", + Text: contentText, + }, + }, + }, + }, + }, + Tags: []Tag{}, + Spaces: spaces, + } + + return c.CreatePost(ctx, req) +} + +// NewTextContent creates a simple text content structure +func NewTextContent(text string) *Content { + return &Content{ + Type: "doc", + Content: []ContentNode{ + { + Type: "paragraph", + Content: []interface{}{ + TextContentNode{ + Type: "text", + Text: text, + }, + }, + }, + }, + } +} + +// PredictTagsSpaces predicts tags and spaces for given text +func (c *OtvetClient) PredictTagsSpaces(ctx context.Context, text string) (*PredictTagsSpacesResponse, error) { + req := PredictTagsSpacesRequest{ + Data: []PredictDataItem{ + { + ID: "0", + Text: text, + }, + }, + } + + body, err := json.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal request") + } + + url := c.baseURL + predictTagsSpacesEndpoint + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body)) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + + // Set headers + httpReq.Header.Set("Content-Type", "application/json") + + // Set authentication cookie + httpReq.AddCookie(&http.Cookie{ + Name: "Auth-Token", + Value: c.config.AuthToken, + }) + + // Perform request + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, errors.Wrap(err, "failed to perform request") + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + // Log error if needed + _ = closeErr + } + }() + + // Read response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + + // Check status code + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, errors.Errorf("otvet.mail.ru returned non-2xx response status: %d, body: %s", resp.StatusCode, string(respBody)) + } + + // Parse response + var predictResp PredictTagsSpacesResponse + if err := json.Unmarshal(respBody, &predictResp); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal response, status: %d, body: %s", resp.StatusCode, string(respBody)) + } + + return &predictResp, nil +} diff --git a/pkg/otvet/dto.go b/pkg/otvet/dto.go new file mode 100644 index 0000000..7d57ac1 --- /dev/null +++ b/pkg/otvet/dto.go @@ -0,0 +1,145 @@ +package otvet + +// CreatePostRequest represents the request body for creating a post +type CreatePostRequest struct { + Content *Content `json:"content"` + ID *int `json:"id,omitempty"` + Poll *Poll `json:"poll,omitempty"` + Spaces []Space `json:"spaces,omitempty"` + Tags []Tag `json:"tags,omitempty"` + Title string `json:"title"` + TopicType int `json:"topic_type"` + Version int `json:"version"` + VisibleTo int `json:"visible_to"` + AuthorID *int `json:"author_id,omitempty"` +} + +// Content represents the content structure +type Content struct { + Type string `json:"type"` + Content []ContentNode `json:"content"` +} + +// ContentNode represents a node in the content tree +type ContentNode struct { + Attrs map[string]interface{} `json:"attrs,omitempty"` + Content []interface{} `json:"content,omitempty"` + Marks []Mark `json:"marks,omitempty"` + Text string `json:"text,omitempty"` + Type string `json:"type"` +} + +// Mark represents a mark in the content +type Mark struct { + Attrs map[string]interface{} `json:"attrs,omitempty"` + Type string `json:"type"` +} + +// Poll represents a poll structure +type Poll struct { + ID int `json:"id"` + Multiple bool `json:"multiple"` + Polls []PollOption `json:"polls"` + Quiz bool `json:"quiz"` + Title string `json:"title"` +} + +// PollOption represents a poll option +type PollOption struct { + Correct bool `json:"correct"` + ID int `json:"id"` + Title string `json:"title"` + VotedByCurrentUser bool `json:"voted_by_current_user"` + Votes int `json:"votes"` +} + +// Space represents a space structure +type Space struct { + Adult bool `json:"adult"` + Banner string `json:"banner,omitempty"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + ID int `json:"id"` + IsPrime bool `json:"is_prime"` + IsSubscribed bool `json:"is_subscribed"` + OrderSpace int `json:"order_space,omitempty"` + Path string `json:"path,omitempty"` + SpaceCounters *SpaceCounters `json:"space_counters,omitempty"` + Title string `json:"title,omitempty"` +} + +// SpaceCounters represents space counters +type SpaceCounters struct { + SubscriptionCount int `json:"subscription_count"` + TopicCount int `json:"topic_count"` +} + +// Tag represents a tag structure +type Tag struct { + ID int `json:"id"` + Name string `json:"name"` + Title string `json:"title"` +} + +// CreatePostResponse represents the response from creating a post +type CreatePostResponse struct { + Result *PostResult `json:"result"` +} + +// PostResult represents the post data in the response +type PostResult struct { + ID int64 `json:"id"` + Title string `json:"title"` + Content *Content `json:"content"` + Author *Author `json:"author"` + RepliesCount int `json:"replies_count"` + RepliesViewCount int `json:"replies_view_count"` + Tags *[]Tag `json:"tags"` + Spaces []Space `json:"spaces"` + TopicType int `json:"topic_type"` + Poll *Poll `json:"poll,omitempty"` + ReactionCounter []ReactionCounter `json:"reaction_counter"` + CreatedAt string `json:"created_at"` + UpdateAt string `json:"update_at"` + IsBookmarked bool `json:"isBookmarked"` + VisibleTo int `json:"visible_to"` +} + +// Author represents the author information +type Author struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + Avatar string `json:"avatar"` + Username string `json:"username"` + Level int `json:"level"` + CreatedAt string `json:"created_at"` + UserStatus int `json:"user_status"` +} + +// ReactionCounter represents a reaction counter +type ReactionCounter struct { + // Add fields as needed when API documentation is available + // For now, keeping it flexible +} + +// PredictTagsSpacesRequest represents the request for tags/spaces prediction +type PredictTagsSpacesRequest struct { + Data []PredictDataItem `json:"data"` +} + +// PredictDataItem represents a single item in the prediction request +type PredictDataItem struct { + ID string `json:"id"` + Text string `json:"text"` +} + +// PredictTagsSpacesResponse represents the response from tags/spaces prediction +type PredictTagsSpacesResponse []PredictResult + +// PredictResult represents a single prediction result +type PredictResult struct { + ID int `json:"id"` + Tags []string `json:"tags"` + Spaces []int `json:"spaces"` + MajorCat int `json:"major_cat"` +} diff --git a/pkg/otvet/init.go b/pkg/otvet/init.go new file mode 100644 index 0000000..63df869 --- /dev/null +++ b/pkg/otvet/init.go @@ -0,0 +1,47 @@ +package otvet + +import ( + "net/http" +) + +// OtvetClient is the client for otvet.mail.ru API +type OtvetClient struct { + config *OtvetClientConfig + httpClient *http.Client + baseURL string +} + +// OtvetClientOption is a function type for client options +type OtvetClientOption func(*OtvetClient) + +// WithBaseURL sets a custom base URL for the client +func WithBaseURL(baseURL string) OtvetClientOption { + return func(c *OtvetClient) { + if baseURL != "" { + c.baseURL = baseURL + } + } +} + +// NewOtvetClient creates a new OtvetClient instance +func NewOtvetClient(config *OtvetClientConfig, opts ...OtvetClientOption) (*OtvetClient, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + httpClient := &http.Client{ + Timeout: config.Timeout, + } + + client := &OtvetClient{ + config: config, + httpClient: httpClient, + baseURL: OtvetBaseURL, + } + + for _, opt := range opts { + opt(client) + } + + return client, nil +} diff --git a/pkg/posting_queue/errors.go b/pkg/posting_queue/errors.go new file mode 100644 index 0000000..91a66cb --- /dev/null +++ b/pkg/posting_queue/errors.go @@ -0,0 +1,8 @@ +package posting_queue + +import "errors" + +var ( + ErrPostNotFound = errors.New("post not found in queue") +) + diff --git a/pkg/posting_queue/queue.go b/pkg/posting_queue/queue.go new file mode 100644 index 0000000..2119c3f --- /dev/null +++ b/pkg/posting_queue/queue.go @@ -0,0 +1,239 @@ +package posting_queue + +import ( + "context" + "math/rand" + "sync" + "time" + + "github.com/google/uuid" + "github.com/goriiin/kotyari-bots_backend/internal/model" + "github.com/goriiin/kotyari-bots_backend/pkg/otvet" +) + +// QueuedPost represents a post waiting to be published +type QueuedPost struct { + ID uuid.UUID + Post *model.Post + Candidate model.Candidate + Request PostRequest + RequiresModeration bool + Approved bool + CreatedAt time.Time +} + +// PostRequest contains information needed to publish a post +type PostRequest struct { + Platform model.PlatformType + PostType model.PostType + TopicType int + Spaces []otvet.Space + // ModerationRequired allows per-request override from bot configuration + ModerationRequired bool +} + +// Account represents an account for posting +type Account struct { + ID string + AuthToken string + Client *otvet.OtvetClient + LastPost time.Time +} + +// Queue is an in-memory queue for posts +type Queue struct { + mu sync.RWMutex + posts []*QueuedPost + accounts map[string]*Account + postingInterval time.Duration + processingInterval time.Duration + moderationRequired bool + stopChan chan struct{} +} + +// NewQueue creates a new posting queue +func NewQueue(postingInterval, processingInterval time.Duration, moderationRequired bool) *Queue { + return &Queue{ + posts: make([]*QueuedPost, 0), + accounts: make(map[string]*Account), + postingInterval: postingInterval, + processingInterval: processingInterval, + moderationRequired: moderationRequired, + stopChan: make(chan struct{}), + } +} + +// AddAccount adds an account to the queue +func (q *Queue) AddAccount(id string, authToken string, client *otvet.OtvetClient) { + q.mu.Lock() + defer q.mu.Unlock() + + q.accounts[id] = &Account{ + ID: id, + AuthToken: authToken, + Client: client, + LastPost: time.Time{}, + } +} + +// Enqueue adds a post to the queue +func (q *Queue) Enqueue(post *model.Post, candidate model.Candidate, req PostRequest) *QueuedPost { + q.mu.Lock() + defer q.mu.Unlock() + + // Determine if moderation is required either by queue default or by per-request flag + requiresModeration := q.moderationRequired || req.ModerationRequired + + queuedPost := &QueuedPost{ + ID: uuid.New(), + Post: post, + Candidate: candidate, + Request: req, + RequiresModeration: requiresModeration, + Approved: !requiresModeration, + CreatedAt: time.Now(), + } + + q.posts = append(q.posts, queuedPost) + return queuedPost +} + +// ApprovePost approves a post for publishing by post ID from database +func (q *Queue) ApprovePost(postID uuid.UUID) error { + q.mu.Lock() + defer q.mu.Unlock() + + for _, post := range q.posts { + if post.Post != nil && post.Post.ID == postID { + post.Approved = true + return nil + } + } + return ErrPostNotFound +} + +// GetPostByID returns a post by post ID from database +func (q *Queue) GetPostByID(postID uuid.UUID) (*QueuedPost, error) { + q.mu.RLock() + defer q.mu.RUnlock() + + for _, post := range q.posts { + if post.Post != nil && post.Post.ID == postID { + return post, nil + } + } + return nil, ErrPostNotFound +} + +// StartProcessing starts the queue processing loop +func (q *Queue) StartProcessing(ctx context.Context, publishFunc func(ctx context.Context, account *Account, post *QueuedPost) error) { + ticker := time.NewTicker(q.processingInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-q.stopChan: + return + case <-ticker.C: + q.processQueue(ctx, publishFunc) + } + } +} + +// Stop stops the queue processing +func (q *Queue) Stop() { + close(q.stopChan) +} + +// processQueue processes the queue and publishes approved posts +func (q *Queue) processQueue(ctx context.Context, publishFunc func(ctx context.Context, account *Account, post *QueuedPost) error) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.accounts) == 0 { + return + } + + // Get random account + account := q.getRandomAccount() + if account == nil { + return + } + + // Check if account can post (respecting posting interval) + if !q.canPost(account) { + return + } + + // Find first approved post (either doesn't require moderation or is approved) + var postToPublish *QueuedPost + postIndex := -1 + + for i, post := range q.posts { + if post.Approved { + postToPublish = post + postIndex = i + break + } + } + + if postToPublish == nil { + return + } + + // Publish post + if err := publishFunc(ctx, account, postToPublish); err != nil { + return + } + + // Update account last post time + account.LastPost = time.Now() + + // Remove post from queue + q.posts = append(q.posts[:postIndex], q.posts[postIndex+1:]...) +} + +// getRandomAccount returns a random account from the map +func (q *Queue) getRandomAccount() *Account { + if len(q.accounts) == 0 { + return nil + } + + accounts := make([]*Account, 0, len(q.accounts)) + for _, acc := range q.accounts { + accounts = append(accounts, acc) + } + + return accounts[rand.Intn(len(accounts))] +} + +// canPost checks if account can post (respecting posting interval) +func (q *Queue) canPost(account *Account) bool { + if account.LastPost.IsZero() { + return true + } + return time.Since(account.LastPost) >= q.postingInterval +} + +// GetQueueSize returns the current queue size +func (q *Queue) GetQueueSize() int { + q.mu.RLock() + defer q.mu.RUnlock() + return len(q.posts) +} + +// GetPendingModerationCount returns count of posts waiting for moderation +func (q *Queue) GetPendingModerationCount() int { + q.mu.RLock() + defer q.mu.RUnlock() + + count := 0 + for _, post := range q.posts { + if post.RequiresModeration && !post.Approved { + count++ + } + } + return count +}