commit 5be5435bb0ab36d5b1021a5a79fb8747e65744a9 Author: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Sat Apr 4 01:28:57 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/api/proto/gateway.pb.go b/api/proto/gateway.pb.go new file mode 100644 index 0000000..9269487 --- /dev/null +++ b/api/proto/gateway.pb.go @@ -0,0 +1,645 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v5.29.2 +// source: gateway.proto + +package gatewaypb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type LoginRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + LoginType string `protobuf:"bytes,1,opt,name=login_type,json=loginType,proto3" json:"login_type,omitempty"` + Account string `protobuf:"bytes,2,opt,name=account,proto3" json:"account,omitempty"` + Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"` + CountryCode string `protobuf:"bytes,4,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"` + VerifyCode string `protobuf:"bytes,5,opt,name=verify_code,json=verifyCode,proto3" json:"verify_code,omitempty"` + Provider string `protobuf:"bytes,6,opt,name=provider,proto3" json:"provider,omitempty"` + ProviderToken string `protobuf:"bytes,7,opt,name=provider_token,json=providerToken,proto3" json:"provider_token,omitempty"` + DeviceId string `protobuf:"bytes,8,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + Platform string `protobuf:"bytes,9,opt,name=platform,proto3" json:"platform,omitempty"` + AppVersion string `protobuf:"bytes,10,opt,name=app_version,json=appVersion,proto3" json:"app_version,omitempty"` +} + +func (x *LoginRequest) Reset() { + *x = LoginRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_gateway_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoginRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginRequest) ProtoMessage() {} + +func (x *LoginRequest) ProtoReflect() protoreflect.Message { + mi := &file_gateway_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. +func (*LoginRequest) Descriptor() ([]byte, []int) { + return file_gateway_proto_rawDescGZIP(), []int{0} +} + +func (x *LoginRequest) GetLoginType() string { + if x != nil { + return x.LoginType + } + return "" +} + +func (x *LoginRequest) GetAccount() string { + if x != nil { + return x.Account + } + return "" +} + +func (x *LoginRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *LoginRequest) GetCountryCode() string { + if x != nil { + return x.CountryCode + } + return "" +} + +func (x *LoginRequest) GetVerifyCode() string { + if x != nil { + return x.VerifyCode + } + return "" +} + +func (x *LoginRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *LoginRequest) GetProviderToken() string { + if x != nil { + return x.ProviderToken + } + return "" +} + +func (x *LoginRequest) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *LoginRequest) GetPlatform() string { + if x != nil { + return x.Platform + } + return "" +} + +func (x *LoginRequest) GetAppVersion() string { + if x != nil { + return x.AppVersion + } + return "" +} + +type UserProfile struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Nickname string `protobuf:"bytes,2,opt,name=nickname,proto3" json:"nickname,omitempty"` + AvatarUrl string `protobuf:"bytes,3,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"` +} + +func (x *UserProfile) Reset() { + *x = UserProfile{} + if protoimpl.UnsafeEnabled { + mi := &file_gateway_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UserProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserProfile) ProtoMessage() {} + +func (x *UserProfile) ProtoReflect() protoreflect.Message { + mi := &file_gateway_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserProfile.ProtoReflect.Descriptor instead. +func (*UserProfile) Descriptor() ([]byte, []int) { + return file_gateway_proto_rawDescGZIP(), []int{1} +} + +func (x *UserProfile) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserProfile) GetNickname() string { + if x != nil { + return x.Nickname + } + return "" +} + +func (x *UserProfile) GetAvatarUrl() string { + if x != nil { + return x.AvatarUrl + } + return "" +} + +type LoginResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + AccessToken string `protobuf:"bytes,2,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + RefreshToken string `protobuf:"bytes,3,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + ExpiresIn int64 `protobuf:"varint,4,opt,name=expires_in,json=expiresIn,proto3" json:"expires_in,omitempty"` + IsNewUser bool `protobuf:"varint,5,opt,name=is_new_user,json=isNewUser,proto3" json:"is_new_user,omitempty"` + Profile *UserProfile `protobuf:"bytes,6,opt,name=profile,proto3" json:"profile,omitempty"` +} + +func (x *LoginResponse) Reset() { + *x = LoginResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_gateway_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoginResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginResponse) ProtoMessage() {} + +func (x *LoginResponse) ProtoReflect() protoreflect.Message { + mi := &file_gateway_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. +func (*LoginResponse) Descriptor() ([]byte, []int) { + return file_gateway_proto_rawDescGZIP(), []int{2} +} + +func (x *LoginResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *LoginResponse) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *LoginResponse) GetRefreshToken() string { + if x != nil { + return x.RefreshToken + } + return "" +} + +func (x *LoginResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +func (x *LoginResponse) GetIsNewUser() bool { + if x != nil { + return x.IsNewUser + } + return false +} + +func (x *LoginResponse) GetProfile() *UserProfile { + if x != nil { + return x.Profile + } + return nil +} + +type QueryOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OrderNo string `protobuf:"bytes,1,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"` +} + +func (x *QueryOrderRequest) Reset() { + *x = QueryOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_gateway_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryOrderRequest) ProtoMessage() {} + +func (x *QueryOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_gateway_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryOrderRequest.ProtoReflect.Descriptor instead. +func (*QueryOrderRequest) Descriptor() ([]byte, []int) { + return file_gateway_proto_rawDescGZIP(), []int{3} +} + +func (x *QueryOrderRequest) GetOrderNo() string { + if x != nil { + return x.OrderNo + } + return "" +} + +type QueryOrderResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + OrderNo string `protobuf:"bytes,1,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Amount string `protobuf:"bytes,4,opt,name=amount,proto3" json:"amount,omitempty"` + Currency string `protobuf:"bytes,5,opt,name=currency,proto3" json:"currency,omitempty"` + Subject string `protobuf:"bytes,6,opt,name=subject,proto3" json:"subject,omitempty"` + PayMethod string `protobuf:"bytes,7,opt,name=pay_method,json=payMethod,proto3" json:"pay_method,omitempty"` + PaidAt string `protobuf:"bytes,8,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"` +} + +func (x *QueryOrderResponse) Reset() { + *x = QueryOrderResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_gateway_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryOrderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryOrderResponse) ProtoMessage() {} + +func (x *QueryOrderResponse) ProtoReflect() protoreflect.Message { + mi := &file_gateway_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryOrderResponse.ProtoReflect.Descriptor instead. +func (*QueryOrderResponse) Descriptor() ([]byte, []int) { + return file_gateway_proto_rawDescGZIP(), []int{4} +} + +func (x *QueryOrderResponse) GetOrderNo() string { + if x != nil { + return x.OrderNo + } + return "" +} + +func (x *QueryOrderResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *QueryOrderResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *QueryOrderResponse) GetAmount() string { + if x != nil { + return x.Amount + } + return "" +} + +func (x *QueryOrderResponse) GetCurrency() string { + if x != nil { + return x.Currency + } + return "" +} + +func (x *QueryOrderResponse) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *QueryOrderResponse) GetPayMethod() string { + if x != nil { + return x.PayMethod + } + return "" +} + +func (x *QueryOrderResponse) GetPaidAt() string { + if x != nil { + return x.PaidAt + } + return "" +} + +var File_gateway_proto protoreflect.FileDescriptor + +var file_gateway_proto_rawDesc = []byte{ + 0x0a, 0x0d, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x19, 0x63, 0x68, 0x61, 0x74, 0x61, 0x70, 0x70, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, + 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x22, 0xc4, 0x02, 0x0a, 0x0c, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, + 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x43, + 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x63, 0x6f, + 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, + 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, + 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x70, 0x70, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x70, 0x70, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x22, 0x61, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, + 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x69, 0x63, + 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x69, 0x63, + 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x5f, + 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, + 0x72, 0x55, 0x72, 0x6c, 0x22, 0xf1, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, + 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, + 0x65, 0x73, 0x5f, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x65, 0x78, 0x70, + 0x69, 0x72, 0x65, 0x73, 0x49, 0x6e, 0x12, 0x1e, 0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x6e, 0x65, 0x77, + 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x4e, + 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, 0x12, 0x40, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, + 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x61, 0x70, + 0x70, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, + 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, + 0x07, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x22, 0x2e, 0x0a, 0x11, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, + 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x4e, 0x6f, 0x22, 0xe6, 0x01, 0x0a, 0x12, 0x51, 0x75, 0x65, + 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x19, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x4e, 0x6f, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x61, + 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x6d, 0x6f, + 0x75, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, + 0x18, 0x0a, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x79, + 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, + 0x61, 0x79, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x61, 0x69, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x69, 0x64, 0x41, + 0x74, 0x32, 0x69, 0x0a, 0x0b, 0x43, 0x68, 0x61, 0x74, 0x41, 0x70, 0x70, 0x55, 0x73, 0x65, 0x72, + 0x12, 0x5a, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x27, 0x2e, 0x63, 0x68, 0x61, 0x74, + 0x61, 0x70, 0x70, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x67, 0x61, 0x74, 0x65, 0x77, + 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x61, 0x70, 0x70, 0x67, 0x61, 0x74, 0x65, + 0x77, 0x61, 0x79, 0x2e, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x77, 0x0a, 0x0a, + 0x43, 0x68, 0x61, 0x74, 0x41, 0x70, 0x70, 0x50, 0x61, 0x79, 0x12, 0x69, 0x0a, 0x0a, 0x51, 0x75, + 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x2c, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x61, + 0x70, 0x70, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, + 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, 0x63, 0x68, 0x61, 0x74, 0x61, 0x70, 0x70, + 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, + 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x24, 0x5a, 0x22, 0x63, 0x68, 0x61, 0x74, 0x61, 0x70, 0x70, + 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x3b, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, +} + +var ( + file_gateway_proto_rawDescOnce sync.Once + file_gateway_proto_rawDescData = file_gateway_proto_rawDesc +) + +func file_gateway_proto_rawDescGZIP() []byte { + file_gateway_proto_rawDescOnce.Do(func() { + file_gateway_proto_rawDescData = protoimpl.X.CompressGZIP(file_gateway_proto_rawDescData) + }) + return file_gateway_proto_rawDescData +} + +var file_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_gateway_proto_goTypes = []any{ + (*LoginRequest)(nil), // 0: chatappgateway.gateway.v1.LoginRequest + (*UserProfile)(nil), // 1: chatappgateway.gateway.v1.UserProfile + (*LoginResponse)(nil), // 2: chatappgateway.gateway.v1.LoginResponse + (*QueryOrderRequest)(nil), // 3: chatappgateway.gateway.v1.QueryOrderRequest + (*QueryOrderResponse)(nil), // 4: chatappgateway.gateway.v1.QueryOrderResponse +} +var file_gateway_proto_depIdxs = []int32{ + 1, // 0: chatappgateway.gateway.v1.LoginResponse.profile:type_name -> chatappgateway.gateway.v1.UserProfile + 0, // 1: chatappgateway.gateway.v1.ChatAppUser.Login:input_type -> chatappgateway.gateway.v1.LoginRequest + 3, // 2: chatappgateway.gateway.v1.ChatAppPay.QueryOrder:input_type -> chatappgateway.gateway.v1.QueryOrderRequest + 2, // 3: chatappgateway.gateway.v1.ChatAppUser.Login:output_type -> chatappgateway.gateway.v1.LoginResponse + 4, // 4: chatappgateway.gateway.v1.ChatAppPay.QueryOrder:output_type -> chatappgateway.gateway.v1.QueryOrderResponse + 3, // [3:5] is the sub-list for method output_type + 1, // [1:3] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_gateway_proto_init() } +func file_gateway_proto_init() { + if File_gateway_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_gateway_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*LoginRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_gateway_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*UserProfile); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_gateway_proto_msgTypes[2].Exporter = func(v any, i int) any { + switch v := v.(*LoginResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_gateway_proto_msgTypes[3].Exporter = func(v any, i int) any { + switch v := v.(*QueryOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_gateway_proto_msgTypes[4].Exporter = func(v any, i int) any { + switch v := v.(*QueryOrderResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_gateway_proto_rawDesc, + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_gateway_proto_goTypes, + DependencyIndexes: file_gateway_proto_depIdxs, + MessageInfos: file_gateway_proto_msgTypes, + }.Build() + File_gateway_proto = out.File + file_gateway_proto_rawDesc = nil + file_gateway_proto_goTypes = nil + file_gateway_proto_depIdxs = nil +} diff --git a/api/proto/gateway.proto b/api/proto/gateway.proto new file mode 100644 index 0000000..b7c22c8 --- /dev/null +++ b/api/proto/gateway.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package chatappgateway.gateway.v1; + +option go_package = "chatappgateway/api/proto;gatewaypb"; + +service ChatAppUser { + rpc Login(LoginRequest) returns (LoginResponse); +} + +service ChatAppPay { + rpc QueryOrder(QueryOrderRequest) returns (QueryOrderResponse); +} + +message LoginRequest { + string login_type = 1; + string account = 2; + string password = 3; + string country_code = 4; + string verify_code = 5; + string provider = 6; + string provider_token = 7; + string device_id = 8; + string platform = 9; + string app_version = 10; +} + +message UserProfile { + string user_id = 1; + string nickname = 2; + string avatar_url = 3; +} + +message LoginResponse { + string user_id = 1; + string access_token = 2; + string refresh_token = 3; + int64 expires_in = 4; + bool is_new_user = 5; + UserProfile profile = 6; +} + +message QueryOrderRequest { + string order_no = 1; +} + +message QueryOrderResponse { + string order_no = 1; + string user_id = 2; + string status = 3; + string amount = 4; + string currency = 5; + string subject = 6; + string pay_method = 7; + string paid_at = 8; +} diff --git a/api/proto/gateway_grpc.pb.go b/api/proto/gateway_grpc.pb.go new file mode 100644 index 0000000..511b5cd --- /dev/null +++ b/api/proto/gateway_grpc.pb.go @@ -0,0 +1,223 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.2 +// source: gateway.proto + +package gatewaypb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ChatAppUser_Login_FullMethodName = "/chatappgateway.gateway.v1.ChatAppUser/Login" +) + +// ChatAppUserClient is the client API for ChatAppUser service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ChatAppUserClient interface { + Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) +} + +type chatAppUserClient struct { + cc grpc.ClientConnInterface +} + +func NewChatAppUserClient(cc grpc.ClientConnInterface) ChatAppUserClient { + return &chatAppUserClient{cc} +} + +func (c *chatAppUserClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LoginResponse) + err := c.cc.Invoke(ctx, ChatAppUser_Login_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ChatAppUserServer is the server API for ChatAppUser service. +// All implementations must embed UnimplementedChatAppUserServer +// for forward compatibility. +type ChatAppUserServer interface { + Login(context.Context, *LoginRequest) (*LoginResponse, error) + mustEmbedUnimplementedChatAppUserServer() +} + +// UnimplementedChatAppUserServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedChatAppUserServer struct{} + +func (UnimplementedChatAppUserServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") +} +func (UnimplementedChatAppUserServer) mustEmbedUnimplementedChatAppUserServer() {} +func (UnimplementedChatAppUserServer) testEmbeddedByValue() {} + +// UnsafeChatAppUserServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ChatAppUserServer will +// result in compilation errors. +type UnsafeChatAppUserServer interface { + mustEmbedUnimplementedChatAppUserServer() +} + +func RegisterChatAppUserServer(s grpc.ServiceRegistrar, srv ChatAppUserServer) { + // If the following call pancis, it indicates UnimplementedChatAppUserServer 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. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ChatAppUser_ServiceDesc, srv) +} + +func _ChatAppUser_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LoginRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatAppUserServer).Login(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatAppUser_Login_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatAppUserServer).Login(ctx, req.(*LoginRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ChatAppUser_ServiceDesc is the grpc.ServiceDesc for ChatAppUser service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ChatAppUser_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "chatappgateway.gateway.v1.ChatAppUser", + HandlerType: (*ChatAppUserServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Login", + Handler: _ChatAppUser_Login_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "gateway.proto", +} + +const ( + ChatAppPay_QueryOrder_FullMethodName = "/chatappgateway.gateway.v1.ChatAppPay/QueryOrder" +) + +// ChatAppPayClient is the client API for ChatAppPay service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ChatAppPayClient interface { + QueryOrder(ctx context.Context, in *QueryOrderRequest, opts ...grpc.CallOption) (*QueryOrderResponse, error) +} + +type chatAppPayClient struct { + cc grpc.ClientConnInterface +} + +func NewChatAppPayClient(cc grpc.ClientConnInterface) ChatAppPayClient { + return &chatAppPayClient{cc} +} + +func (c *chatAppPayClient) QueryOrder(ctx context.Context, in *QueryOrderRequest, opts ...grpc.CallOption) (*QueryOrderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(QueryOrderResponse) + err := c.cc.Invoke(ctx, ChatAppPay_QueryOrder_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ChatAppPayServer is the server API for ChatAppPay service. +// All implementations must embed UnimplementedChatAppPayServer +// for forward compatibility. +type ChatAppPayServer interface { + QueryOrder(context.Context, *QueryOrderRequest) (*QueryOrderResponse, error) + mustEmbedUnimplementedChatAppPayServer() +} + +// UnimplementedChatAppPayServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedChatAppPayServer struct{} + +func (UnimplementedChatAppPayServer) QueryOrder(context.Context, *QueryOrderRequest) (*QueryOrderResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method QueryOrder not implemented") +} +func (UnimplementedChatAppPayServer) mustEmbedUnimplementedChatAppPayServer() {} +func (UnimplementedChatAppPayServer) testEmbeddedByValue() {} + +// UnsafeChatAppPayServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ChatAppPayServer will +// result in compilation errors. +type UnsafeChatAppPayServer interface { + mustEmbedUnimplementedChatAppPayServer() +} + +func RegisterChatAppPayServer(s grpc.ServiceRegistrar, srv ChatAppPayServer) { + // If the following call pancis, it indicates UnimplementedChatAppPayServer 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. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ChatAppPay_ServiceDesc, srv) +} + +func _ChatAppPay_QueryOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryOrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ChatAppPayServer).QueryOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ChatAppPay_QueryOrder_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ChatAppPayServer).QueryOrder(ctx, req.(*QueryOrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ChatAppPay_ServiceDesc is the grpc.ServiceDesc for ChatAppPay service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ChatAppPay_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "chatappgateway.gateway.v1.ChatAppPay", + HandlerType: (*ChatAppPayServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "QueryOrder", + Handler: _ChatAppPay_QueryOrder_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "gateway.proto", +} diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go new file mode 100644 index 0000000..c645361 --- /dev/null +++ b/cmd/gateway/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "chatappgateway/internal/app" + "chatappgateway/internal/config" +) + +func main() { + // 允许通过命令行切换配置文件,便于本地、测试和容器环境复用。 + configPath := flag.String("config", config.DefaultPath, "path to config file") + flag.Parse() + + // 启动阶段先加载配置,避免服务在半初始化状态下对外提供流量。 + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("load config failed", "error", err) + os.Exit(1) + } + + // 使用文本日志,方便直接接入容器日志采集。 + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // 使用进程信号驱动优雅退出。 + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + application, err := app.New(ctx, cfg, logger) + if err != nil { + logger.Error("build application failed", "error", err) + os.Exit(1) + } + defer application.Close() + + if err := application.Run(ctx); err != nil { + logger.Error("application exited with error", "error", err) + os.Exit(1) + } +} diff --git a/config/local.yaml b/config/local.yaml new file mode 100644 index 0000000..9e2a1c9 --- /dev/null +++ b/config/local.yaml @@ -0,0 +1,14 @@ +app: + name: chatappgateway + env: local + http_addr: ":8080" + shutdown_timeout: 10s + +# /ready 会使用这些 gRPC 依赖做接流量前检查。 +grpc: + user: + target: "127.0.0.1:9001" + timeout: 3s + pay: + target: "127.0.0.1:9002" + timeout: 3s diff --git a/docs/ai/第一版 prompt.md b/docs/ai/第一版 prompt.md new file mode 100644 index 0000000..7b169de --- /dev/null +++ b/docs/ai/第一版 prompt.md @@ -0,0 +1,216 @@ +# ChatAppGateway 第一版 prompt + +你是一名资深 Golang 后端架构师,请为一个海外社交 App 实现一个主业务聚合服务 `ChatAppGateway`。 + +## 服务定位 + +- `ChatAppGateway` 不是纯反向代理网关,而是 BFF / 聚合编排服务。 +- 对客户端暴露 HTTP JSON API。 +- 对内通过 gRPC 调用: + - `ChatAppUser.Login` + - `ChatAppPay.QueryOrder` +- 自身不实现用户域认证规则,也不实现支付域核心规则,只做参数校验、字段归一化、协议适配、统一错误响应、日志与 trace。 + +## 目标能力 + +第一版只实现三条 HTTP 接口: + +1. `GET /health` +2. `GET /ready` +3. `POST /api/v1/auth/login` +4. `GET /api/v1/pay/orders/{order_no}` + +## 配置要求 + +配置文件放在 `config/xxx.yaml`,默认读取 `config/local.yaml`。 + +YAML 结构固定如下: + +```yaml +app: + name: chatappgateway + env: local + http_addr: ":8080" + shutdown_timeout: 10s + +grpc: + user: + target: "127.0.0.1:9001" + timeout: 3s + pay: + target: "127.0.0.1:9002" + timeout: 3s +``` + +必须支持: + +- `-config config/local.yaml` 命令行参数 +- `gopkg.in/yaml.v3` +- 默认值补齐 +- 配置校验 + +## 登录接口要求 + +`POST /api/v1/auth/login` + +请求 DTO 至少包含: + +- `login_type` +- `account` +- `password` +- `country_code` +- `verify_code` +- `provider` +- `provider_token` +- `device_id` +- `platform` +- `app_version` + +支持三种登录模式: + +1. `password` + - 必填:`account`、`password` +2. `sms_code` + - 必填:`country_code`、`account`、`verify_code` +3. `oauth` + - 必填:`provider`、`provider_token` + +网关职责: + +- 只做模式校验和字段归一化 +- 将请求映射到 `ChatAppUser.Login` +- 接收用户服务返回后再统一包装给客户端 + +`ChatAppUser.Login` 返回至少包含: + +- `user_id` +- `access_token` +- `refresh_token` +- `expires_in` +- `is_new_user` +- `profile` + +其中 `profile` 至少包含: + +- `user_id` +- `nickname` +- `avatar_url` + +## 支付查询接口要求 + +`GET /api/v1/pay/orders/{order_no}` + +网关职责: + +- 校验 `order_no` 非空 +- 调用 `ChatAppPay.QueryOrder` +- 把支付服务返回的订单结果转成统一 HTTP JSON 响应 + +返回字段至少包含: + +- `order_no` +- `user_id` +- `status` +- `amount` +- `currency` +- `subject` +- `pay_method` +- `paid_at` + +## 健康检查要求 + +`GET /health` + +- 只做网关自身存活检查 +- 固定返回 200 +- 返回: + +```json +{ + "status": "ok", + "service": "chatappgateway" +} +``` + +`GET /ready` + +- 用于 K8s / LB readiness probe +- 不是只判断进程活着 +- 至少要判断: + - 配置已经成功加载 + - 关键依赖已经连通 + - 服务已经具备接流量能力 +- 对 `ChatAppGateway` 第一版来说,`/ready` 至少检查: + - `ChatAppUser` 的 gRPC 连接可用 + - `ChatAppPay` 的 gRPC 连接可用 +- 返回 200 时表示可以接流量,失败时返回 503 + +## 错误码和状态码要求 + +- 参数错误:`400` +- 登录失败或凭证错误:`401` +- 订单不存在:`404` +- 下游 gRPC 超时:`504` +- 下游 gRPC 不可用或内部异常:`502` + +要求统一 JSON 错误结构,例如: + +```json +{ + "code": "bad_request", + "message": "login_type is required", + "request_id": "..." +} +``` + +## 目录结构要求 + +项目目录至少包含: + +- `cmd/gateway/main.go` +- `internal/config` +- `internal/app` +- `internal/transport/http` +- `internal/integration/usergrpc` +- `internal/integration/paygrpc` +- `internal/service/auth` +- `internal/service/pay` +- `api/proto` +- `config/local.yaml` + +## 技术要求 + +- 使用 idiomatic Go +- 使用 `http.ServeMux` +- 使用 `slog` 结构化日志 +- 支持优雅关闭 +- 收到 `SIGTERM` 后先摘除 `/ready`,再停止接新请求,等待旧请求处理完成后退出 +- gRPC 连接使用 `insecure.NewCredentials()` 即可 +- 所有关键代码写中文注释 +- 不要写成教学 demo,要保持生产风格 + +## 测试要求 + +至少覆盖以下场景: + +- 配置加载成功 +- 配置非法时失败 +- `/health` 返回正确 JSON +- `/ready` 在依赖可用时返回 200,在依赖不可用时返回 503 +- 三种 `login_type` 的参数校验 +- 登录请求正确映射到 `ChatAppUser.Login` +- 订单查询正确映射到 `ChatAppPay.QueryOrder` +- user/pay gRPC 超时、不可达、业务错误时的 HTTP 状态码映射 + +## 输出顺序要求 + +请按以下顺序输出实现内容: + +1. 项目目录结构 +2. proto 定义 +3. 配置结构与示例 +4. HTTP API 设计 +5. gRPC client 封装 +6. Go 代码实现 +7. 测试代码 +8. 本地运行说明 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..258d28e --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module chatappgateway + +go 1.23.1 + +require ( + google.golang.org/grpc v1.67.3 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..374ae13 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +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.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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..b0fe2b3 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,86 @@ +package app + +import ( + "context" + "fmt" + "io" + "log/slog" + + gatewaypb "chatappgateway/api/proto" + "chatappgateway/internal/config" + "chatappgateway/internal/integration/paygrpc" + "chatappgateway/internal/integration/usergrpc" + "chatappgateway/internal/service/auth" + "chatappgateway/internal/service/pay" + httpserver "chatappgateway/internal/transport/http" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// Application 组合网关运行所需的全部组件。 +type Application struct { + server *httpserver.Server + closers []io.Closer +} + +// New 构造完整应用,包括 gRPC 连接、业务服务和 HTTP 服务。 +func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Application, error) { + userConn, err := dialGRPC(ctx, cfg.GRPC.User.Target) + if err != nil { + return nil, fmt.Errorf("dial ChatAppUser: %w", err) + } + + payConn, err := dialGRPC(ctx, cfg.GRPC.Pay.Target) + if err != nil { + _ = userConn.Close() + return nil, fmt.Errorf("dial ChatAppPay: %w", err) + } + + userClient := usergrpc.New(gatewaypb.NewChatAppUserClient(userConn), cfg.GRPC.User.Timeout) + payClient := paygrpc.New(gatewaypb.NewChatAppPayClient(payConn), cfg.GRPC.Pay.Timeout) + + authService := auth.New(userClient) + payService := pay.New(payClient) + readinessChecker := newReadinessGroup( + namedChecker{ + name: "ChatAppUser", + check: grpcConnectionReady(userConn), + }, + namedChecker{ + name: "ChatAppPay", + check: grpcConnectionReady(payConn), + }, + ) + server := httpserver.New(cfg.App.Name, cfg.App.HTTPAddr, cfg.App.ShutdownTimeout, logger, authService, payService, readinessChecker) + + return &Application{ + server: server, + closers: []io.Closer{userConn, payConn}, + }, nil +} + +// Run 启动 HTTP 服务。 +func (a *Application) Run(ctx context.Context) error { + return a.server.Run(ctx) +} + +// Close 关闭底层 gRPC 连接。 +func (a *Application) Close() error { + var firstErr error + for _, closer := range a.closers { + if err := closer.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func dialGRPC(ctx context.Context, target string) (*grpc.ClientConn, error) { + // 网关只做自身健康检查,因此这里使用懒连接,避免下游暂时不可用时阻塞启动。 + return grpc.DialContext( + ctx, + target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) +} diff --git a/internal/app/readiness.go b/internal/app/readiness.go new file mode 100644 index 0000000..a9132c7 --- /dev/null +++ b/internal/app/readiness.go @@ -0,0 +1,62 @@ +package app + +import ( + "context" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" +) + +// ReadinessChecker 定义统一的就绪检查能力。 +type ReadinessChecker interface { + Ready(ctx context.Context) error +} + +type readinessGroup struct { + checkers []namedChecker +} + +type namedChecker struct { + name string + check func(context.Context) error +} + +// newReadinessGroup 创建一个按顺序执行的就绪检查器组合。 +func newReadinessGroup(checkers ...namedChecker) ReadinessChecker { + return readinessGroup{checkers: checkers} +} + +// Ready 逐个执行检查,只要有一个失败就返回未就绪。 +func (g readinessGroup) Ready(ctx context.Context) error { + for _, checker := range g.checkers { + if err := checker.check(ctx); err != nil { + return fmt.Errorf("%s not ready: %w", checker.name, err) + } + } + return nil +} + +func grpcConnectionReady(conn *grpc.ClientConn) func(context.Context) error { + return func(ctx context.Context) error { + // 主动触发一次连接过程,避免懒连接状态下 readiness 永远不去拨号。 + conn.Connect() + + for { + state := conn.GetState() + switch state { + case connectivity.Ready: + return nil + case connectivity.Shutdown: + return fmt.Errorf("connection shutdown") + } + + if !conn.WaitForStateChange(ctx, state) { + if err := ctx.Err(); err != nil { + return err + } + return fmt.Errorf("state did not change") + } + } + } +} diff --git a/internal/apperr/error.go b/internal/apperr/error.go new file mode 100644 index 0000000..6d7ea8d --- /dev/null +++ b/internal/apperr/error.go @@ -0,0 +1,58 @@ +package apperr + +import ( + "errors" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Error 表示网关内部统一业务错误。 +type Error struct { + Status int + Code string + Message string +} + +func (e *Error) Error() string { + return e.Message +} + +// New 构造一个统一错误,便于 HTTP 层直接映射。 +func New(status int, code string, message string) *Error { + return &Error{ + Status: status, + Code: code, + Message: message, + } +} + +// Resolve 将任意错误转换成统一 HTTP 状态码和错误码。 +func Resolve(err error) (statusCode int, code string, message string) { + if err == nil { + return 200, "ok", "ok" + } + + var appErr *Error + if errors.As(err, &appErr) { + return appErr.Status, appErr.Code, appErr.Message + } + + grpcStatus, ok := status.FromError(err) + if !ok { + return 500, "internal_error", "internal server error" + } + + switch grpcStatus.Code() { + case codes.InvalidArgument: + return 400, "bad_request", grpcStatus.Message() + case codes.Unauthenticated, codes.PermissionDenied: + return 401, "unauthorized", grpcStatus.Message() + case codes.NotFound: + return 404, "not_found", grpcStatus.Message() + case codes.DeadlineExceeded: + return 504, "upstream_timeout", "upstream request timeout" + default: + return 502, "upstream_error", grpcStatus.Message() + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ea1bdf4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,116 @@ +package config + +import ( + "bytes" + "fmt" + "net" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +const DefaultPath = "config/local.yaml" + +// Config 汇总整个网关服务的运行参数。 +type Config struct { + App AppConfig `yaml:"app"` + GRPC GRPCConfig `yaml:"grpc"` +} + +// AppConfig 描述应用自身参数。 +type AppConfig struct { + Name string `yaml:"name"` + Env string `yaml:"env"` + HTTPAddr string `yaml:"http_addr"` + ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` +} + +// GRPCConfig 聚合所有下游 gRPC 服务配置。 +type GRPCConfig struct { + User UpstreamConfig `yaml:"user"` + Pay UpstreamConfig `yaml:"pay"` +} + +// UpstreamConfig 描述单个下游服务的地址和超时。 +type UpstreamConfig struct { + Target string `yaml:"target"` + Timeout time.Duration `yaml:"timeout"` +} + +// Load 从 YAML 文件加载配置,并补齐默认值和校验。 +func Load(path string) (Config, error) { + // 先读取配置文件内容。 + data, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("read config file %s: %w", path, err) + } + + // 使用 KnownFields 防止配置拼写错误悄悄溜过。 + cfg := defaultConfig() + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { + return Config{}, fmt.Errorf("decode config file %s: %w", path, err) + } + + if err := validate(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func defaultConfig() Config { + return Config{ + App: AppConfig{ + Name: "chatappgateway", + Env: "local", + HTTPAddr: ":8080", + ShutdownTimeout: 10 * time.Second, + }, + GRPC: GRPCConfig{ + User: UpstreamConfig{ + Target: "127.0.0.1:9001", + Timeout: 3 * time.Second, + }, + Pay: UpstreamConfig{ + Target: "127.0.0.1:9002", + Timeout: 3 * time.Second, + }, + }, + } +} + +func validate(cfg Config) error { + // 应用名用于日志标签,不能为空。 + if cfg.App.Name == "" { + return fmt.Errorf("app.name is required") + } + // 监听地址必须能被 TCP 地址解析。 + if _, err := net.ResolveTCPAddr("tcp", cfg.App.HTTPAddr); err != nil { + return fmt.Errorf("app.http_addr is invalid: %w", err) + } + if cfg.App.ShutdownTimeout <= 0 { + return fmt.Errorf("app.shutdown_timeout must be greater than 0") + } + if err := validateUpstream("grpc.user", cfg.GRPC.User); err != nil { + return err + } + if err := validateUpstream("grpc.pay", cfg.GRPC.Pay); err != nil { + return err + } + return nil +} + +func validateUpstream(name string, cfg UpstreamConfig) error { + if cfg.Target == "" { + return fmt.Errorf("%s.target is required", name) + } + if _, err := net.ResolveTCPAddr("tcp", cfg.Target); err != nil { + return fmt.Errorf("%s.target is invalid: %w", name, err) + } + if cfg.Timeout <= 0 { + return fmt.Errorf("%s.timeout must be greater than 0", name) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b8db1ab --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadLocalConfig(t *testing.T) { + t.Parallel() + + cfg, err := Load(filepath.Join("..", "..", "config", "local.yaml")) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if cfg.App.Name != "chatappgateway" { + t.Fatalf("unexpected app name: %s", cfg.App.Name) + } + if cfg.App.HTTPAddr != ":8080" { + t.Fatalf("unexpected http addr: %s", cfg.App.HTTPAddr) + } + if cfg.GRPC.User.Target != "127.0.0.1:9001" { + t.Fatalf("unexpected user target: %s", cfg.GRPC.User.Target) + } + if cfg.GRPC.Pay.Timeout != 3*time.Second { + t.Fatalf("unexpected pay timeout: %s", cfg.GRPC.Pay.Timeout) + } +} + +func TestLoadInvalidConfig(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "invalid.yaml") + content := strings.TrimSpace(` +app: + name: chatappgateway + http_addr: "bad-address" +grpc: + user: + target: "" + timeout: 3s + pay: + target: "127.0.0.1:9002" + timeout: 0s +`) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("Load returned nil error") + } + if !strings.Contains(err.Error(), "app.http_addr is invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoadInvalidUpstream(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "invalid-upstream.yaml") + content := strings.TrimSpace(` +app: + name: chatappgateway + http_addr: ":8080" +grpc: + user: + target: "127.0.0.1:9001" + timeout: 3s + pay: + target: "invalid" + timeout: 3s +`) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("Load returned nil error") + } + if !strings.Contains(err.Error(), "grpc.pay.target is invalid") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/integration/paygrpc/client.go b/internal/integration/paygrpc/client.go new file mode 100644 index 0000000..175a4ea --- /dev/null +++ b/internal/integration/paygrpc/client.go @@ -0,0 +1,30 @@ +package paygrpc + +import ( + "context" + "time" + + gatewaypb "chatappgateway/api/proto" +) + +// Client 封装支付服务 gRPC client,并统一超时控制。 +type Client struct { + timeout time.Duration + client gatewaypb.ChatAppPayClient +} + +// New 根据底层 gRPC client 构造支付服务调用器。 +func New(client gatewaypb.ChatAppPayClient, timeout time.Duration) *Client { + return &Client{ + timeout: timeout, + client: client, + } +} + +// QueryOrder 调用支付服务最小订单查询接口。 +func (c *Client) QueryOrder(ctx context.Context, request *gatewaypb.QueryOrderRequest) (*gatewaypb.QueryOrderResponse, error) { + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + return c.client.QueryOrder(callCtx, request) +} diff --git a/internal/integration/usergrpc/client.go b/internal/integration/usergrpc/client.go new file mode 100644 index 0000000..385fe37 --- /dev/null +++ b/internal/integration/usergrpc/client.go @@ -0,0 +1,30 @@ +package usergrpc + +import ( + "context" + "time" + + gatewaypb "chatappgateway/api/proto" +) + +// Client 封装用户服务 gRPC client,并统一超时控制。 +type Client struct { + timeout time.Duration + client gatewaypb.ChatAppUserClient +} + +// New 根据底层 gRPC client 构造用户服务调用器。 +func New(client gatewaypb.ChatAppUserClient, timeout time.Duration) *Client { + return &Client{ + timeout: timeout, + client: client, + } +} + +// Login 调用用户服务登录接口。 +func (c *Client) Login(ctx context.Context, request *gatewaypb.LoginRequest) (*gatewaypb.LoginResponse, error) { + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + return c.client.Login(callCtx, request) +} diff --git a/internal/service/auth/service.go b/internal/service/auth/service.go new file mode 100644 index 0000000..489fe7c --- /dev/null +++ b/internal/service/auth/service.go @@ -0,0 +1,107 @@ +package auth + +import ( + "context" + "strings" + + gatewaypb "chatappgateway/api/proto" + "chatappgateway/internal/apperr" +) + +// Client 定义用户服务 gRPC 客户端能力。 +type Client interface { + Login(ctx context.Context, request *gatewaypb.LoginRequest) (*gatewaypb.LoginResponse, error) +} + +// Service 负责登录参数校验、字段归一化和下游调用。 +type Service struct { + client Client +} + +// LoginRequest 描述 HTTP 层的登录入参。 +type LoginRequest struct { + LoginType string `json:"login_type"` + Account string `json:"account"` + Password string `json:"password"` + CountryCode string `json:"country_code"` + VerifyCode string `json:"verify_code"` + Provider string `json:"provider"` + ProviderToken string `json:"provider_token"` + DeviceID string `json:"device_id"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` +} + +// New 创建登录服务。 +func New(client Client) *Service { + return &Service{client: client} +} + +// Login 校验客户端请求并转成 gRPC 请求。 +func (s *Service) Login(ctx context.Context, request LoginRequest) (*gatewaypb.LoginResponse, error) { + normalized, err := normalize(request) + if err != nil { + return nil, err + } + + return s.client.Login(ctx, &gatewaypb.LoginRequest{ + LoginType: normalized.LoginType, + Account: normalized.Account, + Password: normalized.Password, + CountryCode: normalized.CountryCode, + VerifyCode: normalized.VerifyCode, + Provider: normalized.Provider, + ProviderToken: normalized.ProviderToken, + DeviceId: normalized.DeviceID, + Platform: normalized.Platform, + AppVersion: normalized.AppVersion, + }) +} + +func normalize(request LoginRequest) (LoginRequest, error) { + normalized := LoginRequest{ + LoginType: strings.ToLower(strings.TrimSpace(request.LoginType)), + Account: strings.TrimSpace(request.Account), + Password: request.Password, + CountryCode: strings.TrimSpace(request.CountryCode), + VerifyCode: strings.TrimSpace(request.VerifyCode), + Provider: strings.ToLower(strings.TrimSpace(request.Provider)), + ProviderToken: strings.TrimSpace(request.ProviderToken), + DeviceID: strings.TrimSpace(request.DeviceID), + Platform: strings.TrimSpace(request.Platform), + AppVersion: strings.TrimSpace(request.AppVersion), + } + + switch normalized.LoginType { + case "": + return LoginRequest{}, apperr.New(400, "bad_request", "login_type is required") + case "password": + if normalized.Account == "" { + return LoginRequest{}, apperr.New(400, "bad_request", "account is required") + } + if strings.TrimSpace(normalized.Password) == "" { + return LoginRequest{}, apperr.New(400, "bad_request", "password is required") + } + case "sms_code": + if normalized.CountryCode == "" { + return LoginRequest{}, apperr.New(400, "bad_request", "country_code is required") + } + if normalized.Account == "" { + return LoginRequest{}, apperr.New(400, "bad_request", "account is required") + } + if normalized.VerifyCode == "" { + return LoginRequest{}, apperr.New(400, "bad_request", "verify_code is required") + } + case "oauth": + if normalized.Provider == "" { + return LoginRequest{}, apperr.New(400, "bad_request", "provider is required") + } + if normalized.ProviderToken == "" { + return LoginRequest{}, apperr.New(400, "bad_request", "provider_token is required") + } + default: + return LoginRequest{}, apperr.New(400, "bad_request", "login_type must be one of password, sms_code, oauth") + } + + return normalized, nil +} diff --git a/internal/service/pay/service.go b/internal/service/pay/service.go new file mode 100644 index 0000000..2b62af7 --- /dev/null +++ b/internal/service/pay/service.go @@ -0,0 +1,36 @@ +package pay + +import ( + "context" + "strings" + + gatewaypb "chatappgateway/api/proto" + "chatappgateway/internal/apperr" +) + +// Client 定义支付服务 gRPC 客户端能力。 +type Client interface { + QueryOrder(ctx context.Context, request *gatewaypb.QueryOrderRequest) (*gatewaypb.QueryOrderResponse, error) +} + +// Service 负责订单号校验和下游支付查询。 +type Service struct { + client Client +} + +// New 创建支付查询服务。 +func New(client Client) *Service { + return &Service{client: client} +} + +// QueryOrder 校验订单号并调用支付服务。 +func (s *Service) QueryOrder(ctx context.Context, orderNo string) (*gatewaypb.QueryOrderResponse, error) { + normalized := strings.TrimSpace(orderNo) + if normalized == "" { + return nil, apperr.New(400, "bad_request", "order_no is required") + } + + return s.client.QueryOrder(ctx, &gatewaypb.QueryOrderRequest{ + OrderNo: normalized, + }) +} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go new file mode 100644 index 0000000..1bd30ed --- /dev/null +++ b/internal/transport/http/server.go @@ -0,0 +1,270 @@ +package httpserver + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" + + gatewaypb "chatappgateway/api/proto" + "chatappgateway/internal/apperr" + "chatappgateway/internal/service/auth" +) + +const orderPrefix = "/api/v1/pay/orders/" + +type requestIDKey struct{} + +// AuthService 定义 HTTP 层依赖的登录业务能力。 +type AuthService interface { + Login(ctx context.Context, request auth.LoginRequest) (*gatewaypb.LoginResponse, error) +} + +// PayService 定义 HTTP 层依赖的支付查询能力。 +type PayService interface { + QueryOrder(ctx context.Context, orderNo string) (*gatewaypb.QueryOrderResponse, error) +} + +// ReadinessChecker 定义就绪检查能力,只有可接流量时才返回 nil。 +type ReadinessChecker interface { + Ready(ctx context.Context) error +} + +// Server 包装整个 HTTP 服务。 +type Server struct { + appName string + addr string + shutdownTimeout time.Duration + logger *slog.Logger + authService AuthService + payService PayService + readiness ReadinessChecker + handler http.Handler + ready atomic.Bool +} + +// New 创建 HTTP 服务实例。 +func New(appName string, addr string, shutdownTimeout time.Duration, logger *slog.Logger, authService AuthService, payService PayService, readiness ReadinessChecker) *Server { + server := &Server{ + appName: appName, + addr: addr, + shutdownTimeout: shutdownTimeout, + logger: logger, + authService: authService, + payService: payService, + readiness: readiness, + } + server.ready.Store(true) + server.handler = server.withRequestContext(server.routes()) + return server +} + +// Handler 返回可供测试复用的 HTTP handler。 +func (s *Server) Handler() http.Handler { + return s.handler +} + +// Run 启动 HTTP 服务并在上下文取消后优雅关闭。 +func (s *Server) Run(ctx context.Context) error { + httpServer := &http.Server{ + Addr: s.addr, + Handler: s.handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + <-ctx.Done() + // 先摘除 readiness,通知上游停止继续发送新流量。 + s.ready.Store(false) + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + + s.logger.Info("http server started", "addr", s.addr) + + err := httpServer.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/ready", s.handleReady) + mux.HandleFunc("/api/v1/auth/login", s.handleLogin) + mux.HandleFunc(orderPrefix, s.handleQueryOrder) + mux.HandleFunc("/", s.handleNotFound) + return mux +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "service": s.appName, + }) +} + +func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { + if !s.ready.Load() { + writeError(w, requestIDFromContext(r.Context()), apperr.New(http.StatusServiceUnavailable, "not_ready", "service is shutting down")) + return + } + + if s.readiness != nil { + checkCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.readiness.Ready(checkCtx); err != nil { + writeError(w, requestIDFromContext(r.Context()), apperr.New(http.StatusServiceUnavailable, "not_ready", err.Error())) + return + } + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ready", + "service": s.appName, + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, requestIDFromContext(r.Context()), apperr.New(http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")) + return + } + + var request auth.LoginRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + writeError(w, requestIDFromContext(r.Context()), apperr.New(http.StatusBadRequest, "bad_request", "invalid json body")) + return + } + + response, err := s.authService.Login(r.Context(), request) + if err != nil { + writeError(w, requestIDFromContext(r.Context()), err) + return + } + + writeEnvelope(w, http.StatusOK, requestIDFromContext(r.Context()), response) +} + +func (s *Server) handleQueryOrder(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, requestIDFromContext(r.Context()), apperr.New(http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")) + return + } + + orderNo, ok := extractOrderNo(r.URL.Path) + if !ok { + writeError(w, requestIDFromContext(r.Context()), apperr.New(http.StatusNotFound, "not_found", "order not found")) + return + } + + response, err := s.payService.QueryOrder(r.Context(), orderNo) + if err != nil { + writeError(w, requestIDFromContext(r.Context()), err) + return + } + + writeEnvelope(w, http.StatusOK, requestIDFromContext(r.Context()), response) +} + +func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { + writeError(w, requestIDFromContext(r.Context()), apperr.New(http.StatusNotFound, "not_found", "route not found")) +} + +func (s *Server) withRequestContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := strings.TrimSpace(r.Header.Get("X-Request-Id")) + if requestID == "" { + requestID = newRequestID() + } + w.Header().Set("X-Request-Id", requestID) + + start := time.Now() + writer := &statusCapturingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} + ctx := context.WithValue(r.Context(), requestIDKey{}, requestID) + next.ServeHTTP(writer, r.WithContext(ctx)) + + s.logger.Info("http request completed", + "request_id", requestID, + "method", r.Method, + "path", r.URL.Path, + "status", writer.statusCode, + "duration", time.Since(start), + ) + }) +} + +func writeEnvelope(w http.ResponseWriter, statusCode int, requestID string, data any) { + writeJSON(w, statusCode, map[string]any{ + "code": "ok", + "message": "ok", + "request_id": requestID, + "data": data, + }) +} + +func writeError(w http.ResponseWriter, requestID string, err error) { + statusCode, code, message := apperr.Resolve(err) + writeJSON(w, statusCode, map[string]any{ + "code": code, + "message": message, + "request_id": requestID, + }) +} + +func writeJSON(w http.ResponseWriter, statusCode int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) +} + +func extractOrderNo(path string) (string, bool) { + if !strings.HasPrefix(path, orderPrefix) { + return "", false + } + orderNo := strings.TrimPrefix(path, orderPrefix) + if orderNo == "" || strings.Contains(orderNo, "/") { + return "", false + } + return orderNo, true +} + +func requestIDFromContext(ctx context.Context) string { + requestID, _ := ctx.Value(requestIDKey{}).(string) + return requestID +} + +func newRequestID() string { + // 优先使用随机值,避免在高并发下出现碰撞。 + buffer := make([]byte, 8) + if _, err := io.ReadFull(rand.Reader, buffer); err == nil { + return hex.EncodeToString(buffer) + } + return strconv.FormatInt(time.Now().UnixNano(), 36) +} + +type statusCapturingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (w *statusCapturingResponseWriter) WriteHeader(statusCode int) { + w.statusCode = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go new file mode 100644 index 0000000..75aa65d --- /dev/null +++ b/internal/transport/http/server_test.go @@ -0,0 +1,555 @@ +package httpserver_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + gatewaypb "chatappgateway/api/proto" + "chatappgateway/internal/integration/paygrpc" + "chatappgateway/internal/integration/usergrpc" + "chatappgateway/internal/service/auth" + "chatappgateway/internal/service/pay" + httpserver "chatappgateway/internal/transport/http" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +func TestHealth(t *testing.T) { + t.Parallel() + + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + resp, body := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/health", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + var payload map[string]string + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if payload["status"] != "ok" || payload["service"] != "chatappgateway" { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestReady(t *testing.T) { + t.Parallel() + + t.Run("ready success", func(t *testing.T) { + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + resp, body := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/ready", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + var payload map[string]string + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if payload["status"] != "ready" || payload["service"] != "chatappgateway" { + t.Fatalf("unexpected payload: %#v", payload) + } + }) + + t.Run("not ready", func(t *testing.T) { + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + env.readinessChecker.err = errors.New("redis not ready") + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/ready", nil) + defer resp.Body.Close() + + assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusServiceUnavailable, "redis not ready") + }) +} + +func TestLoginValidation(t *testing.T) { + t.Parallel() + + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + testCases := []struct { + name string + body string + message string + }{ + { + name: "password missing account", + body: `{"login_type":"password","password":"secret"}`, + message: "account is required", + }, + { + name: "sms_code missing verify_code", + body: `{"login_type":"sms_code","country_code":"+86","account":"13800138000"}`, + message: "verify_code is required", + }, + { + name: "oauth missing provider_token", + body: `{"login_type":"oauth","provider":"google"}`, + message: "provider_token is required", + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + resp, body := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(testCase.body)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + var payload errorResponse + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if payload.Message != testCase.message { + t.Fatalf("unexpected message: %s", payload.Message) + } + }) + } + + if env.userServer.CallCount() != 0 { + t.Fatalf("expected user service not to be called, got %d", env.userServer.CallCount()) + } +} + +func TestLoginDelegatesToUserGRPC(t *testing.T) { + t.Parallel() + + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + env.userServer.response = &gatewaypb.LoginResponse{ + UserId: "u-100", + AccessToken: "access-token", + RefreshToken: "refresh-token", + ExpiresIn: 7200, + IsNewUser: true, + Profile: &gatewaypb.UserProfile{ + UserId: "u-100", + Nickname: "Neo", + AvatarUrl: "https://example.com/avatar.png", + }, + } + + body := `{ + "login_type":" PASSWORD ", + "account":" demo@example.com ", + "password":"secret", + "device_id":" dev-1 ", + "platform":" ios ", + "app_version":" 1.0.0 " + }` + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(body)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(responseBody)) + } + + var payload successResponse[gatewaypb.LoginResponse] + if err := json.Unmarshal(responseBody, &payload); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if payload.Data.UserId != "u-100" { + t.Fatalf("unexpected user id: %s", payload.Data.UserId) + } + + request := env.userServer.LastRequest() + if request == nil { + t.Fatal("expected request to be captured") + } + if request.LoginType != "password" { + t.Fatalf("unexpected login type: %s", request.LoginType) + } + if request.Account != "demo@example.com" { + t.Fatalf("unexpected account: %q", request.Account) + } + if request.DeviceId != "dev-1" { + t.Fatalf("unexpected device id: %q", request.DeviceId) + } + if request.Platform != "ios" { + t.Fatalf("unexpected platform: %q", request.Platform) + } +} + +func TestQueryOrderDelegatesToPayGRPC(t *testing.T) { + t.Parallel() + + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + env.payServer.response = &gatewaypb.QueryOrderResponse{ + OrderNo: "order-001", + UserId: "u-100", + Status: "paid", + Amount: "9.99", + Currency: "USD", + Subject: "VIP", + PayMethod: "apple_pay", + PaidAt: "2026-04-04T12:00:00Z", + } + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/api/v1/pay/orders/order-001", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(responseBody)) + } + + var payload successResponse[gatewaypb.QueryOrderResponse] + if err := json.Unmarshal(responseBody, &payload); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if payload.Data.OrderNo != "order-001" { + t.Fatalf("unexpected order no: %s", payload.Data.OrderNo) + } + + request := env.payServer.LastRequest() + if request == nil || request.OrderNo != "order-001" { + t.Fatalf("unexpected pay request: %#v", request) + } +} + +func TestErrorMapping(t *testing.T) { + t.Parallel() + + t.Run("login unauthorized", func(t *testing.T) { + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + env.userServer.err = status.Error(codes.Unauthenticated, "invalid credentials") + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(`{"login_type":"password","account":"demo","password":"wrong"}`)) + defer resp.Body.Close() + + assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusUnauthorized, "invalid credentials") + }) + + t.Run("pay not found", func(t *testing.T) { + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + env.payServer.err = status.Error(codes.NotFound, "order not found") + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/api/v1/pay/orders/missing-order", nil) + defer resp.Body.Close() + + assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusNotFound, "order not found") + }) + + t.Run("login timeout", func(t *testing.T) { + env := newTestEnv(t, 20*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + env.userServer.delay = 150 * time.Millisecond + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodPost, env.server.URL+"/api/v1/auth/login", strings.NewReader(`{"login_type":"password","account":"demo","password":"secret"}`)) + defer resp.Body.Close() + + assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusGatewayTimeout, "upstream request timeout") + }) + + t.Run("pay unavailable", func(t *testing.T) { + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + env.payServer.err = status.Error(codes.Unavailable, "pay service unavailable") + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/api/v1/pay/orders/order-001", nil) + defer resp.Body.Close() + + assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusBadGateway, "pay service unavailable") + }) +} + +func TestRouteNotFoundReturnsJSON(t *testing.T) { + t.Parallel() + + env := newTestEnv(t, 200*time.Millisecond, 200*time.Millisecond) + defer env.Close() + + resp, responseBody := doRequest(t, env.server.Client(), http.MethodGet, env.server.URL+"/not-exists", nil) + defer resp.Body.Close() + + assertErrorResponse(t, resp.StatusCode, responseBody, http.StatusNotFound, "route not found") +} + +type testEnv struct { + server *httptest.Server + userServer *mockUserServer + payServer *mockPayServer + readinessChecker *mockReadinessChecker + closeFn func() +} + +func (e *testEnv) Close() { + e.server.Close() + e.closeFn() +} + +func newTestEnv(t *testing.T, userTimeout time.Duration, payTimeout time.Duration) *testEnv { + t.Helper() + + userServer := &mockUserServer{ + response: &gatewaypb.LoginResponse{ + UserId: "default-user", + AccessToken: "default-access", + RefreshToken: "default-refresh", + ExpiresIn: 3600, + Profile: &gatewaypb.UserProfile{ + UserId: "default-user", + Nickname: "default", + }, + }, + } + payServer := &mockPayServer{ + response: &gatewaypb.QueryOrderResponse{ + OrderNo: "default-order", + UserId: "default-user", + Status: "pending", + Amount: "0", + Currency: "USD", + }, + } + + userConn, userClose := newBufConnClient(t, func(server *grpc.Server) { + gatewaypb.RegisterChatAppUserServer(server, userServer) + }) + payConn, payClose := newBufConnClient(t, func(server *grpc.Server) { + gatewaypb.RegisterChatAppPayServer(server, payServer) + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + authService := auth.New(usergrpc.New(gatewaypb.NewChatAppUserClient(userConn), userTimeout)) + payService := pay.New(paygrpc.New(gatewaypb.NewChatAppPayClient(payConn), payTimeout)) + readinessChecker := &mockReadinessChecker{} + handler := httpserver.New("chatappgateway", ":0", 2*time.Second, logger, authService, payService, readinessChecker).Handler() + + return &testEnv{ + server: httptest.NewServer(handler), + userServer: userServer, + payServer: payServer, + readinessChecker: readinessChecker, + closeFn: func() { + userClose() + payClose() + }, + } +} + +func newBufConnClient(t *testing.T, register func(*grpc.Server)) (*grpc.ClientConn, func()) { + t.Helper() + + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + register(server) + + go func() { + _ = server.Serve(listener) + }() + + conn, err := grpc.DialContext( + context.Background(), + "bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("DialContext returned error: %v", err) + } + + return conn, func() { + _ = conn.Close() + server.Stop() + _ = listener.Close() + } +} + +func doRequest(t *testing.T, client *http.Client, method string, url string, body io.Reader) (*http.Response, []byte) { + t.Helper() + + request, err := http.NewRequestWithContext(context.Background(), method, url, body) + if err != nil { + t.Fatalf("NewRequestWithContext returned error: %v", err) + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := client.Do(request) + if err != nil { + t.Fatalf("client.Do returned error: %v", err) + } + + payload, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("ReadAll returned error: %v", err) + } + response.Body = io.NopCloser(bytes.NewReader(payload)) + return response, payload +} + +func assertErrorResponse(t *testing.T, gotStatus int, body []byte, wantStatus int, wantMessage string) { + t.Helper() + + if gotStatus != wantStatus { + t.Fatalf("unexpected status: got=%d want=%d body=%s", gotStatus, wantStatus, string(body)) + } + + var payload errorResponse + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if payload.Message != wantMessage { + t.Fatalf("unexpected message: got=%q want=%q", payload.Message, wantMessage) + } + if payload.RequestID == "" { + t.Fatal("request_id should not be empty") + } +} + +type successResponse[T any] struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` + Data T `json:"data"` +} + +type errorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` +} + +type mockUserServer struct { + gatewaypb.UnimplementedChatAppUserServer + + mu sync.Mutex + callCount int + lastReq *gatewaypb.LoginRequest + response *gatewaypb.LoginResponse + err error + delay time.Duration +} + +func (s *mockUserServer) Login(ctx context.Context, request *gatewaypb.LoginRequest) (*gatewaypb.LoginResponse, error) { + s.mu.Lock() + s.callCount++ + copied := *request + s.lastReq = &copied + response := s.response + err := s.err + delay := s.delay + s.mu.Unlock() + + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + if err != nil { + return nil, err + } + return response, nil +} + +func (s *mockUserServer) CallCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.callCount +} + +func (s *mockUserServer) LastRequest() *gatewaypb.LoginRequest { + s.mu.Lock() + defer s.mu.Unlock() + if s.lastReq == nil { + return nil + } + copied := *s.lastReq + return &copied +} + +type mockPayServer struct { + gatewaypb.UnimplementedChatAppPayServer + + mu sync.Mutex + lastReq *gatewaypb.QueryOrderRequest + response *gatewaypb.QueryOrderResponse + err error + delay time.Duration +} + +func (s *mockPayServer) QueryOrder(ctx context.Context, request *gatewaypb.QueryOrderRequest) (*gatewaypb.QueryOrderResponse, error) { + s.mu.Lock() + copied := *request + s.lastReq = &copied + response := s.response + err := s.err + delay := s.delay + s.mu.Unlock() + + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + if err != nil { + return nil, err + } + return response, nil +} + +func (s *mockPayServer) LastRequest() *gatewaypb.QueryOrderRequest { + s.mu.Lock() + defer s.mu.Unlock() + if s.lastReq == nil { + return nil + } + copied := *s.lastReq + return &copied +} + +type mockReadinessChecker struct { + err error +} + +func (c *mockReadinessChecker) Ready(context.Context) error { + return c.err +}