Initial commit

This commit is contained in:
ZuoZuo 2026-04-04 01:28:57 +08:00
commit 5be5435bb0
20 changed files with 2675 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.DS_Store

645
api/proto/gateway.pb.go Normal file
View File

@ -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
}

56
api/proto/gateway.proto Normal file
View File

@ -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;
}

View File

@ -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",
}

47
cmd/gateway/main.go Normal file
View File

@ -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)
}
}

14
config/local.yaml Normal file
View File

@ -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

216
docs/ai/第一版 prompt.md Normal file
View File

@ -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. 本地运行说明

16
go.mod Normal file
View File

@ -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
)

18
go.sum Normal file
View File

@ -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=

86
internal/app/app.go Normal file
View File

@ -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()),
)
}

62
internal/app/readiness.go Normal file
View File

@ -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")
}
}
}
}

58
internal/apperr/error.go Normal file
View File

@ -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()
}
}

116
internal/config/config.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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
}