diff --git a/backend/gateway/controller/castlove_config_controller.go b/backend/gateway/controller/castlove_config_controller.go new file mode 100644 index 0000000..180ce78 --- /dev/null +++ b/backend/gateway/controller/castlove_config_controller.go @@ -0,0 +1,107 @@ +package controller + +import ( + "context" + "net/http" + "time" + + "dubbo.apache.org/dubbo-go/v3/client" + "github.com/gin-gonic/gin" + "github.com/topfans/backend/gateway/pkg/response" + pbCastlove "github.com/topfans/backend/pkg/proto/castlove" + "github.com/topfans/backend/pkg/logger" + "go.uber.org/zap" +) + +// CastloveConfigController 铸爱工艺配置 控制器 +// 端点:GET /api/v1/castlove/config +// 流程:JWT 鉴权 → 透传 Dubbo RPC → 透传 DB → sanitize → 5s 进程内缓存 → JSON 响应 +type CastloveConfigController struct { + castloveConfigClient pbCastlove.CastloveConfigService +} + +// NewCastloveConfigController 创建铸爱工艺配置 Controller 实例 +// dubboClient:指向 assetService(端口 20003)的 Dubbo 客户端 +func NewCastloveConfigController(dubboClient *client.Client) (*CastloveConfigController, error) { + cli, err := pbCastlove.NewCastloveConfigService(dubboClient) + if err != nil { + return nil, err + } + return &CastloveConfigController{ + castloveConfigClient: cli, + }, nil +} + +// GetCastloveConfig 获取铸爱工艺配置(全量分类 + 卡片) +// @Summary 获取铸爱工艺配置 +// @Description 强制鉴权;返回全量分类(星卡/吧唧/海报 等)及其下卡片(含 route_path / route_params) +// @Tags castlove +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response +// @Router /api/v1/castlove/config [get] +func (ctrl *CastloveConfigController) GetCastloveConfig(c *gin.Context) { + logger.Logger.Info("GetCastloveConfig request") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := ctrl.castloveConfigClient.GetCastloveConfig(ctx, &pbCastlove.GetCastloveConfigRequest{}) + if err != nil { + logger.Logger.Error("GetCastloveConfig RPC failed", zap.Error(err)) + response.Error(c, http.StatusInternalServerError, "配置加载失败") + return + } + + if resp.Base == nil || resp.Base.GetCode() != 200 { + // proto Base 为 nil 视为异常,但仍尝试把 categories 透传出去,避免空响应 + logger.Logger.Warn("GetCastloveConfig: unexpected base", + zap.Any("base", resp.Base), + ) + } + + // proto 数组转 []map 以便 gin 序列化为 JSON 数组而非 proto 字段名 + categories := make([]map[string]interface{}, 0, len(resp.Categories)) + for _, cat := range resp.Categories { + crafts := make([]map[string]interface{}, 0, len(cat.Crafts)) + for _, cr := range cat.Crafts { + crafts = append(crafts, map[string]interface{}{ + "id": cr.Id, + "name": cr.Name, + "image_url": cr.ImageUrl, + "route_path": nullableString(cr.RoutePath), // "" → nil,与 DB NULL 行为一致 + "route_params": nullableJSONString(cr.RouteParams), + "sort_order": cr.SortOrder, + }) + } + categories = append(categories, map[string]interface{}{ + "id": cat.Id, + "name": cat.Name, + "type_key": nullableString(cat.TypeKey), + "sort_order": cat.SortOrder, + "crafts": crafts, + }) + } + + response.Success(c, map[string]interface{}{ + "categories": categories, + "version": resp.Version, + }) +} + +// nullableString 空串转 nil(JSON 序列化时为 null,与 DB NULL 行为一致) +func nullableString(s string) interface{} { + if s == "" { + return nil + } + return s +} + +// nullableJSONString 空串转 nil(route_params 同上) +func nullableJSONString(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index becc145..052a97d 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -71,6 +71,11 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl return nil, err } + castloveConfigCtrl, err := controller.NewCastloveConfigController(assetClient) + if err != nil { + return nil, err + } + activityCtrl, err := controller.NewActivityController(activityClient) if err != nil { return nil, err @@ -299,6 +304,15 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl rankings.GET("/original", rankingCtrl.GetOriginalRanking) // 获取自制排行榜 } + // 铸爱工艺配置(需要认证,强制 JWT) + // 流程:gateway → Dubbo RPC → assetService(端口 20003)→ 5s 进程内缓存 → DB + // 后台直连写库,本服务只读 + castlove := v1.Group("/castlove") + castlove.Use(middleware.AuthMiddleware()) + { + castlove.GET("/config", castloveConfigCtrl.GetCastloveConfig) // 获取分类+卡片配置 + } + // 活动相关路由(需要认证) activities := v1.Group("/activities") activities.Use(middleware.AuthMiddleware()) diff --git a/backend/pkg/models/castlove_config.go b/backend/pkg/models/castlove_config.go new file mode 100644 index 0000000..8e50dbd --- /dev/null +++ b/backend/pkg/models/castlove_config.go @@ -0,0 +1,64 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// CastloveCategory 铸爱工艺分类表模型 +// 对应表 castlove_categories;后台直连写库,本服务只读 +type CastloveCategory struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + Name string `gorm:"type:varchar(64);not null;uniqueIndex;column:name"` + TypeKey *string `gorm:"type:varchar(32);column:type_key"` // 外部 deep-link key;可空(NULL 时唯一索引不命中) + SortOrder int32 `gorm:"not null;default:0;column:sort_order"` + IsActive bool `gorm:"not null;default:true;column:is_active"` + CreatedAt int64 `gorm:"not null;column:created_at"` + UpdatedAt int64 `gorm:"not null;column:updated_at"` + DeletedAt *int64 `gorm:"index;column:deleted_at"` +} + +func (CastloveCategory) TableName() string { return "castlove_categories" } + +func (c *CastloveCategory) BeforeCreate(tx *gorm.DB) error { + now := time.Now().UnixMilli() + c.CreatedAt = now + c.UpdatedAt = now + return nil +} + +func (c *CastloveCategory) BeforeUpdate(tx *gorm.DB) error { + c.UpdatedAt = time.Now().UnixMilli() + return nil +} + +// CastloveCraft 铸爱工艺卡片表模型 +// 对应表 castlove_crafts;后台直连写库,本服务只读 +type CastloveCraft struct { + ID int64 `gorm:"primaryKey;autoIncrement;column:id"` + CategoryID int64 `gorm:"not null;index:idx_castlove_crafts_cat_sort;column:category_id"` + Name string `gorm:"type:varchar(64);not null;column:name"` + ImageURL string `gorm:"type:text;not null;column:image_url"` + RoutePath *string `gorm:"type:varchar(255);column:route_path"` // 可空(NULL = 点击弹 toast,不跳转) + RouteParams *string `gorm:"type:jsonb;column:route_params"` // JSON 字符串,NULL 或合法 JSON 对象 + SortOrder int32 `gorm:"not null;default:0;column:sort_order"` + IsActive bool `gorm:"not null;default:true;column:is_active"` + CreatedAt int64 `gorm:"not null;column:created_at"` + UpdatedAt int64 `gorm:"not null;column:updated_at"` + DeletedAt *int64 `gorm:"index;column:deleted_at"` +} + +func (CastloveCraft) TableName() string { return "castlove_crafts" } + +func (c *CastloveCraft) BeforeCreate(tx *gorm.DB) error { + now := time.Now().UnixMilli() + c.CreatedAt = now + c.UpdatedAt = now + return nil +} + +func (c *CastloveCraft) BeforeUpdate(tx *gorm.DB) error { + c.UpdatedAt = time.Now().UnixMilli() + return nil +} diff --git a/backend/pkg/proto/castlove/castlove_config.pb.go b/backend/pkg/proto/castlove/castlove_config.pb.go new file mode 100644 index 0000000..f5d3d92 --- /dev/null +++ b/backend/pkg/proto/castlove/castlove_config.pb.go @@ -0,0 +1,371 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.0 +// source: castlove_config.proto + +package castlove + +import ( + common "github.com/topfans/backend/pkg/proto/common" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +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 CastloveCraftProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` // 卡片ID + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 卡片名("镭射卡" / "光栅卡" / "开发中" / ...) + ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` // 完整 https URL(OSS 域名开头) + RoutePath string `protobuf:"bytes,4,opt,name=route_path,json=routePath,proto3" json:"route_path,omitempty"` // uni-app 页面路径(以 / 开头);空串表示"未配置路由" + RouteParams string `protobuf:"bytes,5,opt,name=route_params,json=routeParams,proto3" json:"route_params,omitempty"` // JSON 字符串(对象);空串表示无参数 + SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` // 同一分类内的相对顺序 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CastloveCraftProto) Reset() { + *x = CastloveCraftProto{} + mi := &file_castlove_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CastloveCraftProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CastloveCraftProto) ProtoMessage() {} + +func (x *CastloveCraftProto) ProtoReflect() protoreflect.Message { + mi := &file_castlove_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CastloveCraftProto.ProtoReflect.Descriptor instead. +func (*CastloveCraftProto) Descriptor() ([]byte, []int) { + return file_castlove_config_proto_rawDescGZIP(), []int{0} +} + +func (x *CastloveCraftProto) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *CastloveCraftProto) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CastloveCraftProto) GetImageUrl() string { + if x != nil { + return x.ImageUrl + } + return "" +} + +func (x *CastloveCraftProto) GetRoutePath() string { + if x != nil { + return x.RoutePath + } + return "" +} + +func (x *CastloveCraftProto) GetRouteParams() string { + if x != nil { + return x.RouteParams + } + return "" +} + +func (x *CastloveCraftProto) GetSortOrder() int32 { + if x != nil { + return x.SortOrder + } + return 0 +} + +// 工艺分类(含其下卡片列表) +type CastloveCategoryProto struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` // 分类ID + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 分类名("星卡" / "吧唧" / "海报") + TypeKey string `protobuf:"bytes,3,opt,name=type_key,json=typeKey,proto3" json:"type_key,omitempty"` // 外部 deep-link key;空串表示无 + SortOrder int32 `protobuf:"varint,4,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` // 分类相对顺序 + Crafts []*CastloveCraftProto `protobuf:"bytes,5,rep,name=crafts,proto3" json:"crafts,omitempty"` // 该分类下的卡片 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CastloveCategoryProto) Reset() { + *x = CastloveCategoryProto{} + mi := &file_castlove_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CastloveCategoryProto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CastloveCategoryProto) ProtoMessage() {} + +func (x *CastloveCategoryProto) ProtoReflect() protoreflect.Message { + mi := &file_castlove_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CastloveCategoryProto.ProtoReflect.Descriptor instead. +func (*CastloveCategoryProto) Descriptor() ([]byte, []int) { + return file_castlove_config_proto_rawDescGZIP(), []int{1} +} + +func (x *CastloveCategoryProto) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *CastloveCategoryProto) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CastloveCategoryProto) GetTypeKey() string { + if x != nil { + return x.TypeKey + } + return "" +} + +func (x *CastloveCategoryProto) GetSortOrder() int32 { + if x != nil { + return x.SortOrder + } + return 0 +} + +func (x *CastloveCategoryProto) GetCrafts() []*CastloveCraftProto { + if x != nil { + return x.Crafts + } + return nil +} + +// 获取铸爱工艺配置请求(无入参,纯 GET) +type GetCastloveConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCastloveConfigRequest) Reset() { + *x = GetCastloveConfigRequest{} + mi := &file_castlove_config_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCastloveConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCastloveConfigRequest) ProtoMessage() {} + +func (x *GetCastloveConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_castlove_config_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCastloveConfigRequest.ProtoReflect.Descriptor instead. +func (*GetCastloveConfigRequest) Descriptor() ([]byte, []int) { + return file_castlove_config_proto_rawDescGZIP(), []int{2} +} + +// 获取铸爱工艺配置响应 +type GetCastloveConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"` + Categories []*CastloveCategoryProto `protobuf:"bytes,2,rep,name=categories,proto3" json:"categories,omitempty"` // 分类(含嵌套卡片) + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` // 所有相关记录 updated_at 最大值(ISO 8601) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCastloveConfigResponse) Reset() { + *x = GetCastloveConfigResponse{} + mi := &file_castlove_config_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCastloveConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCastloveConfigResponse) ProtoMessage() {} + +func (x *GetCastloveConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_castlove_config_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCastloveConfigResponse.ProtoReflect.Descriptor instead. +func (*GetCastloveConfigResponse) Descriptor() ([]byte, []int) { + return file_castlove_config_proto_rawDescGZIP(), []int{3} +} + +func (x *GetCastloveConfigResponse) GetBase() *common.BaseResponse { + if x != nil { + return x.Base + } + return nil +} + +func (x *GetCastloveConfigResponse) GetCategories() []*CastloveCategoryProto { + if x != nil { + return x.Categories + } + return nil +} + +func (x *GetCastloveConfigResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +var File_castlove_config_proto protoreflect.FileDescriptor + +const file_castlove_config_proto_rawDesc = "" + + "\n" + + "\x15castlove_config.proto\x12\x10topfans.castlove\x1a\x12proto/common.proto\"\xb6\x01\n" + + "\x12CastloveCraftProto\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + + "\timage_url\x18\x03 \x01(\tR\bimageUrl\x12\x1d\n" + + "\n" + + "route_path\x18\x04 \x01(\tR\troutePath\x12!\n" + + "\froute_params\x18\x05 \x01(\tR\vrouteParams\x12\x1d\n" + + "\n" + + "sort_order\x18\x06 \x01(\x05R\tsortOrder\"\xb3\x01\n" + + "\x15CastloveCategoryProto\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x19\n" + + "\btype_key\x18\x03 \x01(\tR\atypeKey\x12\x1d\n" + + "\n" + + "sort_order\x18\x04 \x01(\x05R\tsortOrder\x12<\n" + + "\x06crafts\x18\x05 \x03(\v2$.topfans.castlove.CastloveCraftProtoR\x06crafts\"\x1a\n" + + "\x18GetCastloveConfigRequest\"\xb0\x01\n" + + "\x19GetCastloveConfigResponse\x120\n" + + "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12G\n" + + "\n" + + "categories\x18\x02 \x03(\v2'.topfans.castlove.CastloveCategoryProtoR\n" + + "categories\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversion2\x85\x01\n" + + "\x15CastloveConfigService\x12l\n" + + "\x11GetCastloveConfig\x12*.topfans.castlove.GetCastloveConfigRequest\x1a+.topfans.castlove.GetCastloveConfigResponseB8Z6github.com/topfans/backend/pkg/proto/castlove;castloveb\x06proto3" + +var ( + file_castlove_config_proto_rawDescOnce sync.Once + file_castlove_config_proto_rawDescData []byte +) + +func file_castlove_config_proto_rawDescGZIP() []byte { + file_castlove_config_proto_rawDescOnce.Do(func() { + file_castlove_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_castlove_config_proto_rawDesc), len(file_castlove_config_proto_rawDesc))) + }) + return file_castlove_config_proto_rawDescData +} + +var file_castlove_config_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_castlove_config_proto_goTypes = []any{ + (*CastloveCraftProto)(nil), // 0: topfans.castlove.CastloveCraftProto + (*CastloveCategoryProto)(nil), // 1: topfans.castlove.CastloveCategoryProto + (*GetCastloveConfigRequest)(nil), // 2: topfans.castlove.GetCastloveConfigRequest + (*GetCastloveConfigResponse)(nil), // 3: topfans.castlove.GetCastloveConfigResponse + (*common.BaseResponse)(nil), // 4: topfans.common.BaseResponse +} +var file_castlove_config_proto_depIdxs = []int32{ + 0, // 0: topfans.castlove.CastloveCategoryProto.crafts:type_name -> topfans.castlove.CastloveCraftProto + 4, // 1: topfans.castlove.GetCastloveConfigResponse.base:type_name -> topfans.common.BaseResponse + 1, // 2: topfans.castlove.GetCastloveConfigResponse.categories:type_name -> topfans.castlove.CastloveCategoryProto + 2, // 3: topfans.castlove.CastloveConfigService.GetCastloveConfig:input_type -> topfans.castlove.GetCastloveConfigRequest + 3, // 4: topfans.castlove.CastloveConfigService.GetCastloveConfig:output_type -> topfans.castlove.GetCastloveConfigResponse + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_castlove_config_proto_init() } +func file_castlove_config_proto_init() { + if File_castlove_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_castlove_config_proto_rawDesc), len(file_castlove_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_castlove_config_proto_goTypes, + DependencyIndexes: file_castlove_config_proto_depIdxs, + MessageInfos: file_castlove_config_proto_msgTypes, + }.Build() + File_castlove_config_proto = out.File + file_castlove_config_proto_goTypes = nil + file_castlove_config_proto_depIdxs = nil +} diff --git a/backend/pkg/proto/castlove/castlove_config.triple.go b/backend/pkg/proto/castlove/castlove_config.triple.go new file mode 100644 index 0000000..b5f3d7d --- /dev/null +++ b/backend/pkg/proto/castlove/castlove_config.triple.go @@ -0,0 +1,122 @@ +// Code generated by protoc-gen-triple. DO NOT EDIT. +// +// Source: castlove_config.proto +package castlove + +import ( + "context" +) + +import ( + "dubbo.apache.org/dubbo-go/v3" + "dubbo.apache.org/dubbo-go/v3/client" + "dubbo.apache.org/dubbo-go/v3/common" + "dubbo.apache.org/dubbo-go/v3/common/constant" + "dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol" + "dubbo.apache.org/dubbo-go/v3/server" +) + +// This is a compile-time assertion to ensure that this generated file and the Triple package +// are compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of Triple newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of Triple or updating the Triple +// version compiled into your binary. +const _ = triple_protocol.IsAtLeastVersion0_1_0 + +const ( + // CastloveConfigServiceName is the fully-qualified name of the CastloveConfigService service. + CastloveConfigServiceName = "topfans.castlove.CastloveConfigService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // CastloveConfigServiceGetCastloveConfigProcedure is the fully-qualified name of the CastloveConfigService's GetCastloveConfig RPC. + CastloveConfigServiceGetCastloveConfigProcedure = "/topfans.castlove.CastloveConfigService/GetCastloveConfig" +) + +var ( + _ CastloveConfigService = (*CastloveConfigServiceImpl)(nil) +) + +// CastloveConfigService is a client for the topfans.castlove.CastloveConfigService service. +type CastloveConfigService interface { + GetCastloveConfig(ctx context.Context, req *GetCastloveConfigRequest, opts ...client.CallOption) (*GetCastloveConfigResponse, error) +} + +// NewCastloveConfigService constructs a client for the castlove.CastloveConfigService service. +func NewCastloveConfigService(cli *client.Client, opts ...client.ReferenceOption) (CastloveConfigService, error) { + conn, err := cli.DialWithInfo("topfans.castlove.CastloveConfigService", &CastloveConfigService_ClientInfo, opts...) + if err != nil { + return nil, err + } + return &CastloveConfigServiceImpl{ + conn: conn, + }, nil +} + +func SetConsumerCastloveConfigService(srv common.RPCService) { + dubbo.SetConsumerServiceWithInfo(srv, &CastloveConfigService_ClientInfo) +} + +// CastloveConfigServiceImpl implements CastloveConfigService. +type CastloveConfigServiceImpl struct { + conn *client.Connection +} + +func (c *CastloveConfigServiceImpl) GetCastloveConfig(ctx context.Context, req *GetCastloveConfigRequest, opts ...client.CallOption) (*GetCastloveConfigResponse, error) { + resp := new(GetCastloveConfigResponse) + if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetCastloveConfig", opts...); err != nil { + return nil, err + } + return resp, nil +} + +var CastloveConfigService_ClientInfo = client.ClientInfo{ + InterfaceName: "topfans.castlove.CastloveConfigService", + MethodNames: []string{"GetCastloveConfig"}, + ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) { + dubboCli := dubboCliRaw.(*CastloveConfigServiceImpl) + dubboCli.conn = conn + }, +} + +// CastloveConfigServiceHandler is an implementation of the topfans.castlove.CastloveConfigService service. +type CastloveConfigServiceHandler interface { + GetCastloveConfig(context.Context, *GetCastloveConfigRequest) (*GetCastloveConfigResponse, error) +} + +func RegisterCastloveConfigServiceHandler(srv *server.Server, hdlr CastloveConfigServiceHandler, opts ...server.ServiceOption) error { + return srv.Register(hdlr, &CastloveConfigService_ServiceInfo, opts...) +} + +func SetProviderCastloveConfigService(srv common.RPCService) { + dubbo.SetProviderServiceWithInfo(srv, &CastloveConfigService_ServiceInfo) +} + +var CastloveConfigService_ServiceInfo = server.ServiceInfo{ + InterfaceName: "topfans.castlove.CastloveConfigService", + ServiceType: (*CastloveConfigServiceHandler)(nil), + Methods: []server.MethodInfo{ + { + Name: "GetCastloveConfig", + Type: constant.CallUnary, + ReqInitFunc: func() interface{} { + return new(GetCastloveConfigRequest) + }, + MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) { + req := args[0].(*GetCastloveConfigRequest) + res, err := handler.(CastloveConfigServiceHandler).GetCastloveConfig(ctx, req) + if err != nil { + return nil, err + } + return triple_protocol.NewResponse(res), nil + }, + }, + }, +} diff --git a/backend/proto/castlove_config.proto b/backend/proto/castlove_config.proto new file mode 100644 index 0000000..5aaebab --- /dev/null +++ b/backend/proto/castlove_config.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package topfans.castlove; + +option go_package = "github.com/topfans/backend/pkg/proto/castlove;castlove"; + +import "proto/common.proto"; + +// ==================== 铸爱工艺配置 ==================== + +// 工艺卡片(精简版,只包含前端展示所需字段) +message CastloveCraftProto { + int64 id = 1; // 卡片ID + string name = 2; // 卡片名("镭射卡" / "光栅卡" / "开发中" / ...) + string image_url = 3; // 完整 https URL(OSS 域名开头) + string route_path = 4; // uni-app 页面路径(以 / 开头);空串表示"未配置路由" + string route_params = 5; // JSON 字符串(对象);空串表示无参数 + int32 sort_order = 6; // 同一分类内的相对顺序 +} + +// 工艺分类(含其下卡片列表) +message CastloveCategoryProto { + int64 id = 1; // 分类ID + string name = 2; // 分类名("星卡" / "吧唧" / "海报") + string type_key = 3; // 外部 deep-link key;空串表示无 + int32 sort_order = 4; // 分类相对顺序 + repeated CastloveCraftProto crafts = 5; // 该分类下的卡片 +} + +// 获取铸爱工艺配置请求(无入参,纯 GET) +message GetCastloveConfigRequest { +} + +// 获取铸爱工艺配置响应 +message GetCastloveConfigResponse { + topfans.common.BaseResponse base = 1; + repeated CastloveCategoryProto categories = 2; // 分类(含嵌套卡片) + string version = 3; // 所有相关记录 updated_at 最大值(ISO 8601) +} + +// ==================== 铸爱工艺配置服务 ==================== +// 部署在 assetService(端口 20003),由 gateway 通过 Dubbo Triple 转发 +// 前端路径:GET /api/v1/castlove/config + +service CastloveConfigService { + // 获取铸爱工艺配置(全量分类+卡片) + rpc GetCastloveConfig(GetCastloveConfigRequest) returns (GetCastloveConfigResponse); +} diff --git a/backend/services/assetService/main.go b/backend/services/assetService/main.go index d6743d7..af396c4 100644 --- a/backend/services/assetService/main.go +++ b/backend/services/assetService/main.go @@ -20,6 +20,7 @@ import ( "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" pbAsset "github.com/topfans/backend/pkg/proto/asset" + pbCastlove "github.com/topfans/backend/pkg/proto/castlove" pbRanking "github.com/topfans/backend/pkg/proto/ranking" pbUser "github.com/topfans/backend/pkg/proto/user" assetClient "github.com/topfans/backend/services/assetService/client" @@ -115,6 +116,7 @@ func main() { relationRepo := repository.NewAssetMaterialRelationRepository(database.GetDB()) mintCostRepo := repository.NewMintCostRepository() userMintCountRepo := repository.NewUserMintCountRepository() + castloveConfigRepo := repository.NewCastloveConfigRepository(database.GetDB()) logger.Logger.Info("Repository layer initialized") // 创建 Dubbo 客户端 @@ -145,11 +147,13 @@ func main() { assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB(), assetLevelSvc) rankingService := service.NewRankingService(rankingRepo, assetLikeRepo, userClient) materialService := service.NewMaterialService(materialRepo, relationRepo) + castloveConfigService := service.NewCastloveConfigService(castloveConfigRepo) logger.Logger.Info("Service layer initialized") // 创建 Provider 层实例 assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService, materialService) rankingProvider := provider.NewRankingProvider(rankingService) + castloveConfigProvider := provider.NewCastloveConfigProvider(castloveConfigService) logger.Logger.Info("Provider layer initialized") // 启动赛季重置 Worker(每小时检查一次) @@ -185,6 +189,11 @@ func main() { logger.Logger.Fatal(fmt.Sprintf("Failed to register Ranking Service: %v", err)) } + // 注册 Castlove Config Service + if err := pbCastlove.RegisterCastloveConfigServiceHandler(srv, castloveConfigProvider); err != nil { + logger.Logger.Fatal(fmt.Sprintf("Failed to register Castlove Config Service: %v", err)) + } + // 启动服务 if err := srv.Serve(); err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to start Asset Service: %v", err)) @@ -226,6 +235,9 @@ func autoMigrate() error { &models.LaserCardTemplate{}, &models.LaserCardInstance{}, &models.LaserCardOperationLog{}, + // 铸爱工艺配置(只读,后台直连写库;此处 AutoMigrate 仅做兜底,实际表结构由外部 SQL 维护) + &models.CastloveCategory{}, + &models.CastloveCraft{}, } for _, table := range tables { diff --git a/backend/services/assetService/provider/castlove_config_provider.go b/backend/services/assetService/provider/castlove_config_provider.go new file mode 100644 index 0000000..baabbcd --- /dev/null +++ b/backend/services/assetService/provider/castlove_config_provider.go @@ -0,0 +1,52 @@ +package provider + +import ( + "context" + + "github.com/topfans/backend/pkg/logger" + pbCastlove "github.com/topfans/backend/pkg/proto/castlove" + "github.com/topfans/backend/services/assetService/service" + "go.uber.org/zap" +) + +// CastloveConfigProvider 铸爱工艺配置 Provider +// 实现 Triple 协议生成的 CastloveConfigServiceHandler 接口 +// 部署在 assetService 内(端口 20003),由 gateway 通过 Dubbo Triple 调用 +// +// 注意:protoc-gen-go-triple v3 不生成 Unimplemented*Handler(不像 grpc-go), +// 只需直接实现接口方法即可。 +type CastloveConfigProvider struct { + castloveConfigSvc service.CastloveConfigService +} + +// 编译期断言:CastloveConfigProvider 必须实现 CastloveConfigServiceHandler 接口 +// (protoc 生成 *.triple.go 后,上面 UnimplementedCastloveConfigServiceHandler 才能解析) +var _ pbCastlove.CastloveConfigServiceHandler = (*CastloveConfigProvider)(nil) + +// NewCastloveConfigProvider 创建铸爱工艺配置 Provider 实例 +func NewCastloveConfigProvider(svc service.CastloveConfigService) *CastloveConfigProvider { + return &CastloveConfigProvider{ + castloveConfigSvc: svc, + } +} + +// GetCastloveConfig 获取铸爱工艺配置 +// RPC:CastloveConfigService.GetCastloveConfig +// 鉴权:由 gateway 层 AuthMiddleware 强制 JWT,本服务信任 ctx 透传的 user_id +func (p *CastloveConfigProvider) GetCastloveConfig(ctx context.Context, req *pbCastlove.GetCastloveConfigRequest) (*pbCastlove.GetCastloveConfigResponse, error) { + logger.Logger.Info("Received GetCastloveConfig request") + + resp, err := p.castloveConfigSvc.GetConfig(ctx) + if err != nil { + logger.Logger.Error("GetCastloveConfig failed", zap.Error(err)) + return &pbCastlove.GetCastloveConfigResponse{ + Base: resp.Base, // 若有 base 则保留(可能含部分状态);否则为 nil,由调用方判空 + }, err + } + + logger.Logger.Debug("GetCastloveConfig successful", + zap.Int("categories_count", len(resp.Categories)), + zap.String("version", resp.Version), + ) + return resp, nil +} diff --git a/backend/services/assetService/repository/castlove_config_repo.go b/backend/services/assetService/repository/castlove_config_repo.go new file mode 100644 index 0000000..ade5387 --- /dev/null +++ b/backend/services/assetService/repository/castlove_config_repo.go @@ -0,0 +1,85 @@ +package repository + +import ( + "github.com/topfans/backend/pkg/models" + "gorm.io/gorm" +) + +// CastloveConfigRepository 铸爱工艺配置 Repository 接口 +type CastloveConfigRepository interface { + // ListActiveCategories 读取所有 is_active=true 且未软删的分类 + // 返回按 sort_order ASC, id ASC 排序 + ListActiveCategories() ([]*models.CastloveCategory, error) + + // ListActiveCrafts 读取所有 is_active=true 且未软删的卡片 + // 返回按 sort_order ASC, id ASC 排序 + ListActiveCrafts() ([]*models.CastloveCraft, error) + + // MaxUpdatedAt 返回两表所有相关记录 updated_at 最大值(毫秒) + // 用于构造 version 字段(便于调试/条件请求) + MaxUpdatedAt() (int64, error) +} + +type castloveConfigRepository struct { + db *gorm.DB +} + +// NewCastloveConfigRepository 创建铸爱工艺配置 Repository 实例 +func NewCastloveConfigRepository(db *gorm.DB) CastloveConfigRepository { + return &castloveConfigRepository{db: db} +} + +// ListActiveCategories 读取所有 is_active=true 且未软删的分类 +func (r *castloveConfigRepository) ListActiveCategories() ([]*models.CastloveCategory, error) { + var categories []*models.CastloveCategory + err := r.db. + Where("is_active = ? AND deleted_at IS NULL", true). + Order("sort_order ASC, id ASC"). + Find(&categories).Error + if err != nil { + return nil, err + } + return categories, nil +} + +// ListActiveCrafts 读取所有 is_active=true 且未软删的卡片 +func (r *castloveConfigRepository) ListActiveCrafts() ([]*models.CastloveCraft, error) { + var crafts []*models.CastloveCraft + err := r.db. + Where("is_active = ? AND deleted_at IS NULL", true). + Order("sort_order ASC, id ASC"). + Find(&crafts).Error + if err != nil { + return nil, err + } + return crafts, nil +} + +// MaxUpdatedAt 返回两表所有相关记录 updated_at 最大值(毫秒) +// category_max = MAX(updated_at) FROM castlove_categories WHERE is_active AND !deleted +// craft_max = MAX(updated_at) FROM castlove_crafts WHERE is_active AND !deleted +// 返回两者中的较大值 +func (r *castloveConfigRepository) MaxUpdatedAt() (int64, error) { + var catMax, craftMax int64 + + if err := r.db. + Model(&models.CastloveCategory{}). + Select("COALESCE(MAX(updated_at), 0)"). + Where("is_active = ? AND deleted_at IS NULL", true). + Scan(&catMax).Error; err != nil { + return 0, err + } + + if err := r.db. + Model(&models.CastloveCraft{}). + Select("COALESCE(MAX(updated_at), 0)"). + Where("is_active = ? AND deleted_at IS NULL", true). + Scan(&craftMax).Error; err != nil { + return 0, err + } + + if catMax > craftMax { + return catMax, nil + } + return craftMax, nil +} diff --git a/backend/services/assetService/service/castlove_config_service.go b/backend/services/assetService/service/castlove_config_service.go new file mode 100644 index 0000000..9359547 --- /dev/null +++ b/backend/services/assetService/service/castlove_config_service.go @@ -0,0 +1,215 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + "sync/atomic" + "time" + + "github.com/topfans/backend/pkg/logger" + pbCastlove "github.com/topfans/backend/pkg/proto/castlove" + pbCommon "github.com/topfans/backend/pkg/proto/common" + "github.com/topfans/backend/pkg/models" + "github.com/topfans/backend/services/assetService/repository" + "go.uber.org/zap" + "golang.org/x/sync/singleflight" +) + +// CastloveConfigService 铸爱工艺配置 Service 接口 +type CastloveConfigService interface { + // GetConfig 读取全量配置(含 5s 进程内缓存 + singleflight 防击穿) + // 返回已 sanitize 后的数据 + GetConfig(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) +} + +// 缓存 TTL:5 秒(spec §3.4) +// 平衡点:端上体验 vs DB 压力;运营反馈"改完看不到要等 5 秒太久"时可调到 2 秒 +const castloveConfigCacheTTL = 5 * time.Second + +// castloveConfigService 实现 +type castloveConfigService struct { + repo repository.CastloveConfigRepository + + // 进程内缓存:原子指针,避免锁 + cache atomic.Pointer[castloveConfigCacheEntry] + + // singleflight:多并发同时 miss 时只打一次 DB + sfGroup singleflight.Group +} + +// castloveConfigCacheEntry 缓存条目 +type castloveConfigCacheEntry struct { + data *pbCastlove.GetCastloveConfigResponse + expiresAt time.Time +} + +// NewCastloveConfigService 创建铸爱工艺配置 Service 实例 +func NewCastloveConfigService(repo repository.CastloveConfigRepository) CastloveConfigService { + return &castloveConfigService{ + repo: repo, + } +} + +// GetConfig 读取全量配置(含 5s 进程内缓存 + singleflight 防击穿) +func (s *castloveConfigService) GetConfig(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) { + // 1. 命中缓存 + if c := s.cache.Load(); c != nil && time.Now().Before(c.expiresAt) { + return c.data, nil + } + + // 2. 缓存未命中或已过期,走 singleflight(多并发只打一次 DB) + v, err, _ := s.sfGroup.Do("castlove_config", func() (interface{}, error) { + return s.loadFromDB(ctx) + }) + if err != nil { + return nil, err + } + + resp := v.(*pbCastlove.GetCastloveConfigResponse) + + // 3. 写入缓存(覆盖式) + s.cache.Store(&castloveConfigCacheEntry{ + data: resp, + expiresAt: time.Now().Add(castloveConfigCacheTTL), + }) + + return resp, nil +} + +// loadFromDB 从 DB 读取并组装 + sanitize +// (后台直连写库,这里做最低限度的兜底校验,防止脏数据穿透到前端) +func (s *castloveConfigService) loadFromDB(ctx context.Context) (*pbCastlove.GetCastloveConfigResponse, error) { + categories, err := s.repo.ListActiveCategories() + if err != nil { + logger.Logger.Error("castlove_config: list categories failed", zap.Error(err)) + return nil, err + } + + crafts, err := s.repo.ListActiveCrafts() + if err != nil { + logger.Logger.Error("castlove_config: list crafts failed", zap.Error(err)) + return nil, err + } + + maxUpdatedAt, err := s.repo.MaxUpdatedAt() + if err != nil { + // 失败不致命,降级 version = 当前时间 + logger.Logger.Warn("castlove_config: maxUpdatedAt failed, fallback to now", zap.Error(err)) + maxUpdatedAt = time.Now().UnixMilli() + } + + // 构造 category_id -> []*craft 的 map,方便 O(1) 归类 + craftsByCategory := make(map[int64][]*models.CastloveCraft, len(categories)) + for _, c := range crafts { + craftsByCategory[c.CategoryID] = append(craftsByCategory[c.CategoryID], c) + } + + // 构造响应 + out := &pbCastlove.GetCastloveConfigResponse{ + Base: &pbCommon.BaseResponse{ + Code: pbCommon.StatusCode_STATUS_OK, + Message: "ok", + Timestamp: time.Now().UnixMilli(), + }, + Categories: make([]*pbCastlove.CastloveCategoryProto, 0, len(categories)), + Version: time.UnixMilli(maxUpdatedAt).UTC().Format(time.RFC3339), + } + + for _, cat := range categories { + protoCat := &pbCastlove.CastloveCategoryProto{ + Id: cat.ID, + Name: cat.Name, + SortOrder: cat.SortOrder, + Crafts: make([]*pbCastlove.CastloveCraftProto, 0), + } + if cat.TypeKey != nil { + protoCat.TypeKey = *cat.TypeKey + } + + // 归类该分类下的卡片 + rawCrafts := craftsByCategory[cat.ID] + for _, raw := range rawCrafts { + // sanitize 兜底:返回 nil 表示丢弃 + sanitized := sanitizeCastloveCraft(raw) + if sanitized == nil { + continue + } + protoCat.Crafts = append(protoCat.Crafts, sanitized) + } + + out.Categories = append(out.Categories, protoCat) + } + + return out, nil +} + +// sanitizeCastloveCraft 兜底校验 +// 返回 nil 表示该卡片应被丢弃(日志 warn,不抛错) +// 规则(spec §3.5): +// - image_url 必须 http(s) 开头 +// - name 非空 +// - route_path 若非空:必须以 / 开头,且不含 ? / # +// - route_params 若非空:必须是合法 JSON object +func sanitizeCastloveCraft(c *models.CastloveCraft) *pbCastlove.CastloveCraftProto { + // 1. image_url 必须 http(s) 开头 + if !strings.HasPrefix(c.ImageURL, "http://") && !strings.HasPrefix(c.ImageURL, "https://") { + logger.Logger.Warn("castlove_config: drop craft, invalid image_url", + zap.Int64("craft_id", c.ID), + zap.String("image_url", c.ImageURL), + ) + return nil + } + + // 2. name 非空 + if strings.TrimSpace(c.Name) == "" { + logger.Logger.Warn("castlove_config: drop craft, empty name", + zap.Int64("craft_id", c.ID), + ) + return nil + } + + // 3. route_path 兜底 + routePath := "" + if c.RoutePath != nil { + raw := strings.TrimSpace(*c.RoutePath) + if raw != "" { + if !strings.HasPrefix(raw, "/") || strings.ContainsAny(raw, "?#") { + logger.Logger.Warn("castlove_config: clear route_path (invalid format)", + zap.Int64("craft_id", c.ID), + zap.String("route_path", raw), + ) + // 降级为 NULL/空(行为同"未配置") + raw = "" + } + } + routePath = raw + } + + // 4. route_params 兜底:必须是合法 JSON object + routeParams := "" + if c.RouteParams != nil { + raw := strings.TrimSpace(*c.RouteParams) + if raw != "" { + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &m); err != nil { + logger.Logger.Warn("castlove_config: clear route_params (not JSON object)", + zap.Int64("craft_id", c.ID), + zap.String("route_params", raw), + zap.Error(err), + ) + raw = "" + } + } + routeParams = raw + } + + return &pbCastlove.CastloveCraftProto{ + Id: c.ID, + Name: c.Name, + ImageUrl: c.ImageURL, + RoutePath: routePath, + RouteParams: routeParams, + SortOrder: c.SortOrder, + } +} diff --git a/docs/frontend-api-consistency-audit.md b/docs/frontend-api-consistency-audit.md new file mode 100644 index 0000000..bafbb80 --- /dev/null +++ b/docs/frontend-api-consistency-audit.md @@ -0,0 +1,227 @@ +# 前端 API 接口格式统一性审查报告 + +> 审查时间:2026-06-09 +> 范围:`frontend/` 下所有源码(`unpackage/dist` 等编译产物已排除) +> 核心封装器:`frontend/utils/api.js` 的 `request(options)` + +--- + +## 0. TL;DR + +整体架构是统一的:所有 JSON 接口走同一 `request()` 入口,token 注入、401 跳转、响应解析、Mock 短路都集中在 `utils/api.js`。 + +**主要问题不在封装本身,而在一批"绕过封装"的散点**: + +- **OSS 上传/下载**没有沉淀到 `api.js`,导致 5 个页面 + 3 个 utils 各自实现一遍 +- **20+ 处业务侧**重复判断 `code === 200`(封装器已保证) +- **2 处历史遗留** URL 仍走旧版 `/api/user/*`(无 `/v1`) +- **3 个模块** 自己造了 `request` 风格的 `uni.request` 轮子 + +优先级 P0:把 OSS 直传抽到 `utils/api.js`,可一次性消掉 30+ 处重复代码。 + +--- + +## 1. 已统一的良好部分 ✅ + +### 1.1 入口封装 — 95% 的 JSON 接口走 `request()` + +- 唯一封装器:`frontend/utils/api.js` 的 `request(options)`(L53–L134) +- `frontend/utils/api.js` 内 60+ 个 API + `frontend/utils/task-api.js` 的 11 个 API 全部走 `request()` +- 全项目 **46 个文件** 直接 import `@/utils/api` 或 `@/utils/task-api`,**无相对路径污染** + +### 1.2 Token 注入 + +- 自动从 `uni.getStorageSync('access_token')` 读取 +- Header 格式统一为 `Authorization: Bearer ` +- 白名单(不注入 token):`/api/v1/auth/login|register|send-code|verify-code` + +### 1.3 业务响应结构 + +后端约定:`{ code, message, data }`,封装器在 `request()` 内统一处理: + +| 输入 | 行为 | +|---|---| +| `HTTP 200/202` + `code === 200` | resolve 整体响应 | +| `HTTP 200/202` + `code === 401/400/403` | 清 token + 跳登录 + reject | +| `HTTP 200/202` + 其他 `code` | reject(`Error(message)`) | +| `HTTP 200/202` + 无 `code` 字段 | resolve(`res.data`) ← 兜底 | +| `HTTP 401` | 清 token + 跳登录 + reject | +| 其他 HTTP 状态码 | reject(`Error(message || '请求失败 (statusCode)')`) | + +### 1.4 环境与 Mock 开关 + +- `VITE_API_BASE_URL` / `VITE_USE_MOCK_API` 集中读取 +- `IS_MOCK_API` 暴露给业务做短路 +- `getWebSocketBaseUrl` / `getSegmentApiBaseUrl` / `getLaserApiBaseUrl` 三个 baseURL 工厂统一 + +### 1.5 Dashboard 模块有专门封装 + +`dashboardApi` 对象 + `dashboardRequest()` 工厂(`utils/api.js` L999–L1007),统一了 mock 短路与 baseURL 前缀。 + +### 1.6 WebSocket 入口统一 + +`utils/socket/SocketManager.js` 通过 `getWebSocketBaseUrl()` 工厂函数拿到 baseURL(L48–L49),token 拼在 query 上(`?token=Bearer_`),鉴权方式与 HTTP 通道保持一致。 + +--- + +## 2. 不一致点(按严重度排序) + +### 🔴 严重:页面层直连 `uni.request` / `uni.uploadFile` / `fetch` + +下列文件**绕过了 `request()` 封装**,需要各自维护 token 注入、超时、错误处理、401 跳转。表中行号定位到调用点。 + +| 文件 | 行号 | 用途 | 风险点 | +|---|---|---|---| +| `pages/discover/discover.vue` | L101, L134 | OSS PostObject | 重复实现 fetch + uploadFile 分支 | +| `pages/discover/generation-result.vue` | L316, L418, L495, L653 | OSS + 上传 | 4 处独立直传,逻辑高度相似 | +| `pages/castlove/index.vue` | L261, L409 | 图片下载/上传 | 自行处理 token | +| `pages/castlove/create.vue` | L430, L527, L540, L569, L614 | 全链路直传 | 5 处,逻辑重复 | +| `pages/castlove/lenticular/lenticular-create.vue` | L331, L427, L440, L468, L512 | 全链路直传 | 5 处 | +| `pages/castlove/lenticular/lenticular-result.vue` | L217, L319, L396, L554 | 全链路直传 | 4 处 | +| `pages/square/components/WaterfallGrid.vue` | L712, L735, L777, L832 | 业务请求 | 4 处 `res.code === 200` | +| `pages/square/components/HotCategoryBlock.vue` | L193 | 业务请求 | `code === 200` 重复判断 | +| `pages/profile/profile.vue` | L1187 | 头像上传 | 自处理 statusCode | +| `pages/profile/setNickname.vue` | L183 | 头像上传 | 自处理 statusCode,**无 token 注入** | +| `pages/starbook/items.vue` | L136 | 业务请求 | `response.code === 200` | + +### 🟡 中等:业务侧重复判断 `code === 200` + +`request()` 已经对 `code === 200` resolve 整体,业务侧**不应**再判断。下列位置违反封装契约: + +``` +utils/progress-manager.js:180 +utils/activity-config.js:46, 69, 87, 105, 126 +utils/craftMintSubmit.js:481, 510, 627, 690 +pages/square/components/WaterfallGrid.vue:712, 735, 777, 832 +pages/square/components/HotCategoryBlock.vue:193 +pages/starbook/items.vue:136 +pages/discover/discover.vue:195, 223 +pages/discover/generation-result.vue:284 +composables/useLaserMint.js:42 +``` + +### 🟡 中等:部分模块自己造 `request` 风格轮子 + +| 文件 | 位置 | 说明 | +|---|---|---| +| `composables/useLaserDifyGenerate.js` | L19–L47 | 独立 `laserRequest()`,自行注入 token、超时、状态码判断,文件内注释自承 "返回格式与 api.js 的 request() 一致" | +| `utils/laser-card/segmentationCloud.js` | L21–L39 | `uniRequest()` 重复实现,仅做状态码判断 | +| `utils/laser-card/aliyunPortraitUni.js` | L273 | `uni.request` 走 OSS PostObject,因 OSS 协议需要单独处理 `x-oss-date` 等特殊头 | +| `utils/craftMintSubmit.js` | L21, L57, L70, L399, L407, L423, L451 | 5 处 `uni.uploadFile`/`fetch` 混用,H5/App 分支自管 | + +> 这些位置**有合理理由**绕过 `request()`(OSS 直传需自定义头、需 `multipart/form-data`),但应**抽到 `utils/api.js`** 而不是散布在业务模块。 + +### 🟡 中等:URL 风格不一致 + +虽然都带 `/api/v1` 前缀,但**功能模块 URL 风格混乱**: + +- 资源 URL:`/api/v1/mygalleries`(无 `/me` 也没有 `/users`)vs `/api/v1/galleries/:uid` vs `/api/v1/galleries/random`(`utils/api.js` L431–L452) +- 历史遗留:`updateUserInfoApi` L215 仍走 `/api/user/update`(无 `/v1`),`deleteAccountApi` L276 走 `/api/user/delete-account` +- 单复数、是否用 `/me` 表示当前用户不统一: + - `/api/v1/auth/me` + - `/api/v1/me/nickname` + - `/api/v1/me/avatar` + - `/api/v1/me/liked-assets` + - `/api/v1/me/exhibited-assets` + - `/api/v1/users/:uid/liked-assets` + +### 🟢 轻微:拼装 URL 的方式不统一 + +- 多数用模板字符串拼在 `url` 内(`/api/v1/social/users?page=${page}&page_size=${pageSize}`,`utils/api.js` L226) +- 也有用 `data` 字段传 query 的(GET 请求,如 `task-api.js` L15 `getDailyTasks` 把 `star_id` 放 `data` 字段,uni.request 在 GET 下会拼到 query) +- 统一规范缺失,新人很容易混用 + +### 🟢 轻微:`request` 默认 timeout 与 `uni.uploadFile` 超时不一致 + +- `request()` 默认 60000ms(L78) +- `segmentPortraitApi` 用 120000ms(L749) +- `downloadToLocal` 用 120000ms(`composables/useLaserSegment.js` L29) +- 没有集中常量 + +--- + +## 3. 统计摘要 + +| 指标 | 数值 | +|---|---| +| 直接 import `@/utils/api` 的文件 | 46 | +| 仍走 `uni.request` 直连的文件 | 7 | +| 仍走 `uni.uploadFile` 直连的文件 | 8 | +| 仍走 `fetch()` 直连的文件 | 5 | +| 业务侧 `code === 200` 二次判断处 | 20+ | +| URL 路径里残留 `/api/user/*` 旧接口 | 2 | +| 重复实现的 `request` 风格封装 | 3(`laserRequest`、`uniRequest`、`laserRequest`) | + +--- + +## 4. 修复建议(按 ROI 排序) + +### P0 — 把"绕过封装"的 OSS 直传抽到 `api.js` + +新增一组 OSS 专用工具到 `utils/api.js`: + +```js +// 上传 OSS (H5: fetch + FormData / App: uni.uploadFile) +export function uploadToOss({ host, dir, filePath, policy, signature, ... }) { ... } + +// 下载 OSS (H5: fetch + blob URL / App: uni.downloadFile) +export function downloadFromOss(signedUrl) { ... } +``` + +**收益**:消掉 5 个页面、3 个 utils 中重复的 30+ 处直传代码;统一 token 注入;统一超时;统一错误处理。 + +### P1 — 修复 `/api/user/*` 旧路径 + +- `updateUserInfoApi`(L215):`/api/user/update` → 对应的 v1 路径 +- `deleteAccountApi`(L276):`/api/user/delete-account` → `/api/v1/me/account` 之类 + +### P1 — 业务侧禁用 `code === 200` 二次判断 + +封装器已经处理,业务层应只 try/catch。可在 `request()` 里加注释: + +```js +// 业务侧请勿重复判断 code === 200,封装器已保证 resolve 时 code === 200 +``` + +### P2 — URL 风格规范化 + +建议对齐: + +- 当前用户 → `/me`(已用):`/api/v1/me/liked-assets`、`/api/v1/me/exhibited-assets` 等等 +- 指定用户 → `/users/:uid`:`/api/v1/users/:uid/liked-assets` +- 收藏/资源 → 单数主语,操作作子路径:`/api/v1/galleries/...` 而不是 `/api/v1/mygalleries` + +### P2 — 提取 `api.js` 默认 timeout 常量 + +```js +const DEFAULT_TIMEOUT = 60000 +const UPLOAD_TIMEOUT = 120000 +export const API_TIMEOUT = { + default: DEFAULT_TIMEOUT, + upload: UPLOAD_TIMEOUT, + download: UPLOAD_TIMEOUT, +} +``` + +### P3 — 拼装 query 的工具函数 + +```js +export function buildQuery(params) { + return '?' + new URLSearchParams(params).toString() +} +``` + +让所有 `getXxxApi` 走同一方式,而非分散在 `url`/`data` 两处。 + +--- + +## 5. 后续 Action Items + +| 优先级 | 任务 | 涉及文件数 | 预估工时 | +|---|---|---|---| +| P0 | 把 OSS 上传/下载抽到 `utils/api.js` | 8 | 4h | +| P1 | 修复 `/api/user/*` 旧路径 | 2 | 0.5h | +| P1 | 业务侧清除 20+ 处 `code === 200` 二次判断 | 10+ | 1h | +| P2 | URL 风格规范化 + `/me` 对齐 | 20+ | 2h | +| P2 | 提取 `API_TIMEOUT` 常量 | 3 | 0.5h | +| P3 | 引入 `buildQuery` 工具函数 | 30+ | 1h | diff --git a/frontend/pages/castlove/craft-select.vue b/frontend/pages/castlove/craft-select.vue index 06f643a..a39705f 100644 --- a/frontend/pages/castlove/craft-select.vue +++ b/frontend/pages/castlove/craft-select.vue @@ -12,10 +12,34 @@ @touchmove="onTouchMove" @touchend="onTouchEnd" > - + + + + + + + + {{ loadError }} + 点击重试 + + + + + + 暂无内容 + + + + + + {{ currentCategoryName }}敬请期待 + + + + - + + @@ -38,16 +68,18 @@ {{ card.name }} - - - - + + + {{ category.name }} - + @@ -78,6 +113,8 @@