diff --git a/.gitignore b/.gitignore index e145787..e9e553c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ data/* /tmp/ /app/api +**/__debug_bin*.exe \ No newline at end of file diff --git a/app/main/api/desc/front/tianyuan.api b/app/main/api/desc/front/tianyuan.api new file mode 100644 index 0000000..da7f104 --- /dev/null +++ b/app/main/api/desc/front/tianyuan.api @@ -0,0 +1,19 @@ +syntax = "v1" + +info ( + title: "天远异步回调" + desc: "天远车辆类接口异步回调入口" + version: "v1" +) + +// 该服务只提供第三方回调入口,不依赖登录态 +@server ( + prefix: api/v1 + group: tianyuan +) +service main { + @doc "天远车辆类接口异步回调" + @handler vehicleCallback + post /tianyuan/vehicle/callback +} + diff --git a/app/main/api/desc/front/upload.api b/app/main/api/desc/front/upload.api new file mode 100644 index 0000000..6fd5958 --- /dev/null +++ b/app/main/api/desc/front/upload.api @@ -0,0 +1,39 @@ +syntax = "v1" + +info ( + title: "上传" + desc: "图片上传,用于行驶证等需 URL 的接口" + version: "v1" +) + +type ( + UploadImageReq { + ImageBase64 string `json:"image_base64" validate:"required"` // 图片 base64(不含 data URL 前缀) + } + UploadImageResp { + Url string `json:"url"` // 可公网访问的图片 URL + } + ServeUploadedFileReq { + FileName string `path:"fileName"` // 文件名,如 uuid.jpg + } + // 实际由 Handler 根据 FilePath/ContentType 写文件流,不返回 JSON + ServeUploadedFileResp { + FilePath string `json:"-"` // 内部:本地文件路径 + ContentType string `json:"-"` // 内部:Content-Type + } +) + +@server ( + prefix: api/v1 + group: upload +) +service main { + @doc "上传图片,返回可访问 URL(如行驶证);限制 3MB" + @handler UploadImage + post /upload/image (UploadImageReq) returns (UploadImageResp) + + @doc "访问已上传文件(供第三方或前端通过返回的 URL 拉取)" + @handler ServeUploadedFile + get /upload/file/:fileName (ServeUploadedFileReq) returns (ServeUploadedFileResp) +} + diff --git a/app/main/api/desc/main.api b/app/main/api/desc/main.api index afb7210..e8a13d2 100644 --- a/app/main/api/desc/main.api +++ b/app/main/api/desc/main.api @@ -14,6 +14,8 @@ import "./front/product.api" import "./front/agent.api" import "./front/app.api" import "./front/authorization.api" +import "./front/upload.api" +import "./front/tianyuan.api" // 后台 import "./admin/auth.api" import "./admin/menu.api" diff --git a/app/main/api/etc/main.dev.yaml b/app/main/api/etc/main.dev.yaml index 76cbef6..01feefd 100644 --- a/app/main/api/etc/main.dev.yaml +++ b/app/main/api/etc/main.dev.yaml @@ -86,4 +86,7 @@ Tianyuanapi: Timeout: 60 Authorization: FileBaseURL: "https://www.tianyuancha.cn/api/v1/auth-docs" # 授权书文件访问基础URL +Upload: + FileBaseURL: "https://www.tianyuancha.cn/api/v1/upload/file" # 上传图片访问基础 URL + TempFileMaxAgeH: 24 # 临时文件保留时长(小时),超时自动删除,0 表示默认 24 ExtensionTime: 24 # 佣金解冻延迟时间,单位:24小时 diff --git a/app/main/api/etc/main.yaml b/app/main/api/etc/main.yaml index 5e57048..487cedb 100644 --- a/app/main/api/etc/main.yaml +++ b/app/main/api/etc/main.yaml @@ -95,4 +95,6 @@ Tianyuanapi: Timeout: 60 Authorization: FileBaseURL: "https://www.tianyuancha.cn/api/v1/auth-docs" # 授权书文件访问基础URL +Upload: + FileBaseURL: "https://www.tianyuancha.cn/api/v1/upload/file" # 上传图片访问基础 URL(行驶证等) ExtensionTime: 24 # 佣金解冻延迟时间,单位:24小时 diff --git a/app/main/api/internal/config/config.go b/app/main/api/internal/config/config.go index 51e425f..f112b6c 100644 --- a/app/main/api/internal/config/config.go +++ b/app/main/api/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { SystemConfig SystemConfig WechatH5 WechatH5Config Authorization AuthorizationConfig // 授权书配置 + Upload UploadConfig // 图片上传(行驶证等)配置 WechatMini WechatMiniConfig Query QueryConfig AdminConfig AdminConfig @@ -136,3 +137,9 @@ type TianyuanapiConfig struct { type AuthorizationConfig struct { FileBaseURL string // 授权书文件访问基础URL } + +// UploadConfig 图片上传(行驶证等)配置,临时存储,按 hash 去重 +type UploadConfig struct { + FileBaseURL string `json:",optional"` // 上传文件访问基础 URL,如 https://xxx/api/v1/upload/file + TempFileMaxAgeH int `json:",optional"` // 临时文件保留时长(小时),超时删除,0 表示默认 24 小时 +} diff --git a/app/main/api/internal/handler/routes.go b/app/main/api/internal/handler/routes.go index 318ea8e..2159ed1 100644 --- a/app/main/api/internal/handler/routes.go +++ b/app/main/api/internal/handler/routes.go @@ -26,6 +26,8 @@ import ( pay "tyc-server/app/main/api/internal/handler/pay" product "tyc-server/app/main/api/internal/handler/product" query "tyc-server/app/main/api/internal/handler/query" + tianyuan "tyc-server/app/main/api/internal/handler/tianyuan" + upload "tyc-server/app/main/api/internal/handler/upload" user "tyc-server/app/main/api/internal/handler/user" "tyc-server/app/main/api/internal/svc" @@ -770,7 +772,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.UserDisableInterceptor, serverCtx.UserAuthInterceptor}, + []rest.Middleware{serverCtx.UserAuthInterceptor}, []rest.Route{ { Method: http.MethodGet, @@ -810,7 +812,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.UserDisableInterceptor, serverCtx.UserAuthInterceptor}, + []rest.Middleware{serverCtx.UserAuthInterceptor}, []rest.Route{ { Method: http.MethodPost, @@ -830,7 +832,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.UserDisableInterceptor, serverCtx.UserAuthInterceptor}, + []rest.Middleware{serverCtx.UserAuthInterceptor}, []rest.Route{ { Method: http.MethodGet, @@ -880,7 +882,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.AuthInterceptor, serverCtx.UserDisableInterceptor}, + []rest.Middleware{serverCtx.AuthInterceptor}, []rest.Route{ { Method: http.MethodPost, @@ -987,7 +989,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.UserDisableInterceptor, serverCtx.UserAuthInterceptor}, + []rest.Middleware{serverCtx.UserAuthInterceptor}, []rest.Route{ { Method: http.MethodPost, @@ -1039,7 +1041,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.AuthInterceptor, serverCtx.UserDisableInterceptor}, + []rest.Middleware{serverCtx.AuthInterceptor}, []rest.Route{ { // query service agent @@ -1059,7 +1061,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.UserDisableInterceptor, serverCtx.UserAuthInterceptor}, + []rest.Middleware{serverCtx.UserAuthInterceptor}, []rest.Route{ { // query service @@ -1075,7 +1077,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.UserDisableInterceptor, serverCtx.UserAuthInterceptor}, + []rest.Middleware{serverCtx.UserAuthInterceptor}, []rest.Route{ { // 生成分享链接 @@ -1148,6 +1150,36 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1"), ) + server.AddRoutes( + []rest.Route{ + { + // 天远车辆类接口异步回调 + Method: http.MethodPost, + Path: "/tianyuan/vehicle/callback", + Handler: tianyuan.VehicleCallbackHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1"), + ) + + server.AddRoutes( + []rest.Route{ + { + // 访问已上传文件(供第三方或前端通过返回的 URL 拉取) + Method: http.MethodGet, + Path: "/upload/file/:fileName", + Handler: upload.ServeUploadedFileHandler(serverCtx), + }, + { + // 上传图片,返回可访问 URL(如行驶证);限制 3MB + Method: http.MethodPost, + Path: "/upload/image", + Handler: upload.UploadImageHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1"), + ) + server.AddRoutes( []rest.Route{ { @@ -1179,7 +1211,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( rest.WithMiddlewares( - []rest.Middleware{serverCtx.AuthInterceptor, serverCtx.UserDisableInterceptor}, + []rest.Middleware{serverCtx.AuthInterceptor}, []rest.Route{ { // 绑定手机号 diff --git a/app/main/api/internal/handler/tianyuan/vehiclecallbackhandler.go b/app/main/api/internal/handler/tianyuan/vehiclecallbackhandler.go new file mode 100644 index 0000000..27198e4 --- /dev/null +++ b/app/main/api/internal/handler/tianyuan/vehiclecallbackhandler.go @@ -0,0 +1,33 @@ +package tianyuan + +import ( + "net/http" + + tianyuanlogic "tyc-server/app/main/api/internal/logic/tianyuan" + "tyc-server/app/main/api/internal/svc" + "tyc-server/common/result" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// VehicleCallbackHandler 天远车辆类接口异步回调入口 +// 约定:第三方在回调 URL 上携带 order_no / api_id 等标识,例如:/api/v1/tianyuan/vehicle/callback?order_no=Q_xxx&api_id=QCXG1U4U +// 回调 Body 为该接口最终的 JSON 结果。 +func VehicleCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := tianyuanlogic.NewVehicleCallbackLogic(r.Context(), svcCtx) + if err := l.Handle(r); err != nil { + // 对第三方尽量返回 200,避免无限重试,这里使用统一 result 封装错误 + result.HttpResult(r, w, map[string]interface{}{ + "code": 500, + "msg": "fail", + }, err) + return + } + + httpx.OkJson(w, map[string]interface{}{ + "code": 200, + "msg": "success", + }) + } +} diff --git a/app/main/api/internal/handler/upload/serveuploadedfilehandler.go b/app/main/api/internal/handler/upload/serveuploadedfilehandler.go new file mode 100644 index 0000000..06aaf7e --- /dev/null +++ b/app/main/api/internal/handler/upload/serveuploadedfilehandler.go @@ -0,0 +1,47 @@ +package upload + +import ( + "net/http" + "os" + + "tyc-server/app/main/api/internal/logic/upload" + "tyc-server/app/main/api/internal/svc" + "tyc-server/app/main/api/internal/types" + "tyc-server/common/result" + "tyc-server/pkg/lzkit/validator" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ServeUploadedFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ServeUploadedFileReq + if err := httpx.Parse(r, &req); err != nil { + result.ParamErrorResult(r, w, err) + return + } + if err := validator.Validate(req); err != nil { + result.ParamValidateErrorResult(r, w, err) + return + } + l := upload.NewServeUploadedFileLogic(r.Context(), svcCtx) + resp, err := l.ServeUploadedFile(&req) + if err != nil { + result.HttpResult(r, w, nil, err) + return + } + if resp != nil && resp.FilePath != "" { + f, openErr := os.Open(resp.FilePath) + if openErr != nil { + result.HttpResult(r, w, nil, openErr) + return + } + defer f.Close() + w.Header().Set("Content-Type", resp.ContentType) + w.WriteHeader(http.StatusOK) + _, _ = f.WriteTo(w) + return + } + httpx.OkJson(w, resp) + } +} diff --git a/app/main/api/internal/handler/upload/uploadimagehandler.go b/app/main/api/internal/handler/upload/uploadimagehandler.go new file mode 100644 index 0000000..7ab0dd0 --- /dev/null +++ b/app/main/api/internal/handler/upload/uploadimagehandler.go @@ -0,0 +1,29 @@ +package upload + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "tyc-server/app/main/api/internal/logic/upload" + "tyc-server/app/main/api/internal/svc" + "tyc-server/app/main/api/internal/types" + "tyc-server/common/result" + "tyc-server/pkg/lzkit/validator" +) + +func UploadImageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UploadImageReq + if err := httpx.Parse(r, &req); err != nil { + result.ParamErrorResult(r, w, err) + return + } + if err := validator.Validate(req); err != nil { + result.ParamValidateErrorResult(r, w, err) + return + } + l := upload.NewUploadImageLogic(r.Context(), svcCtx) + resp, err := l.UploadImage(&req) + result.HttpResult(r, w, resp, err) + } +} diff --git a/app/main/api/internal/logic/query/queryservicelogic.go b/app/main/api/internal/logic/query/queryservicelogic.go index fb98767..17b431c 100644 --- a/app/main/api/internal/logic/query/queryservicelogic.go +++ b/app/main/api/internal/logic/query/queryservicelogic.go @@ -86,6 +86,23 @@ var productHandlers = map[string]queryHandlerFunc{ "toc_VehicleMaintenanceDetail": runVehicleMaintenanceDetailReq, "toc_VehicleClaimDetail": runVehicleClaimDetailReq, "toc_VehicleClaimVerify": runVehicleClaimVerifyReq, + // 核验工具(verify feature.md) + "toc_PoliceTwoFactors": runVerifyAuthTwoReq, + "toc_PoliceThreeFactors": runVerifyAuthThreeReq, + "toc_ProfessionalCertificate": runVerifyCertReq, + "toc_PersonalConsumptionCapacityLevel": runVerifyConsumptionReq, // 个人消费能力(沿用现有 product_en) + "toc_OperatorTwoFactors": runVerifyYysTwoReq, + "toc_MobileThreeFactors": runVerifyYysThreeReq, + "toc_NumberRecycle": runVerifyMobileOnlyReq, + "toc_MobileEmptyCheck": runVerifyMobileOnlyReq, + "toc_MobilePortability": runVerifyMobileOnlyReq, + "toc_MobileOnlineStatus": runVerifyMobileOnlyReq, + "toc_MobileOnlineDuration": runVerifyMobileOnlyReq, + "toc_MobileAttribution": runVerifyMobileOnlyReq, + "toc_MobileConsumptionRange": runVerifyYysConsumptionReq, + "toc_EnterpriseRelation": runVerifyEntRelationReq, + "toc_BankcardFourFactors": runVerifyBankFourReq, + "toc_BankcardBlacklist": runVerifyBankBlackReq, } func (l *QueryServiceLogic) PreprocessLogic(req *types.QueryServiceReq, product string) (*types.QueryServiceResp, error) { @@ -242,8 +259,10 @@ func runVehicleVinCodeReq(l *QueryServiceLogic, decryptData []byte, product stri if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } } return map[string]interface{}{ "vin_code": data.VinCode, @@ -259,13 +278,15 @@ func runVehicleMileageMixedReq(l *QueryServiceLogic, decryptData []byte, product if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } } + // 回调地址由后端在 ApiRequestService 中统一生成,此处不再下发 return_url return map[string]interface{}{ - "vin_code": data.VinCode, - "return_url": data.ReturnURL, - "image_url": data.ImageURL, + "vin_code": data.VinCode, + "image_url": data.ImageURL, }, nil } @@ -278,8 +299,10 @@ func runVehicleVinValuationReq(l *QueryServiceLogic, decryptData []byte, product if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } } return map[string]interface{}{ "vin_code": data.VinCode, @@ -297,13 +320,15 @@ func runVehicleTransferSimpleReq(l *QueryServiceLogic, decryptData []byte, produ if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } } return map[string]interface{}{"vin_code": data.VinCode}, nil } -// runVehicleMaintenanceSimpleReq 车辆维保简版 QCXG3Y6B(仅必填 vin_code, return_url) +// runVehicleMaintenanceSimpleReq 车辆维保简版 QCXG3Y6B(仅必填 vin_code;回调地址后端自动生成) func runVehicleMaintenanceSimpleReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { var data types.TocVehicleMaintenanceSimpleReq if unmarshalErr := json.Unmarshal(decryptData, &data); unmarshalErr != nil { @@ -312,16 +337,18 @@ func runVehicleMaintenanceSimpleReq(l *QueryServiceLogic, decryptData []byte, pr if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } } + // 回调地址由后端在 ApiRequestService 中统一生成,此处不再下发 return_url return map[string]interface{}{ - "vin_code": data.VinCode, - "return_url": data.ReturnURL, + "vin_code": data.VinCode, }, nil } -// runVehicleMaintenanceDetailReq 车辆维保详细版 QCXG3Z3L(仅必填 vin_code, return_url) +// runVehicleMaintenanceDetailReq 车辆维保详细版 QCXG3Z3L(仅必填 vin_code;回调地址后端自动生成) func runVehicleMaintenanceDetailReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { var data types.TocVehicleMaintenanceDetailReq if unmarshalErr := json.Unmarshal(decryptData, &data); unmarshalErr != nil { @@ -330,16 +357,18 @@ func runVehicleMaintenanceDetailReq(l *QueryServiceLogic, decryptData []byte, pr if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } } + // 回调地址由后端在 ApiRequestService 中统一生成,此处不再下发 return_url return map[string]interface{}{ - "vin_code": data.VinCode, - "return_url": data.ReturnURL, + "vin_code": data.VinCode, }, nil } -// runVehicleClaimDetailReq 车辆出险详版 QCXGP00W(仅必填 vin_code, return_url, vlphoto_data),vlphoto_data 由 API 层加密为 data +// runVehicleClaimDetailReq 车辆出险详版 QCXGP00W(仅必填 vin_code, vlphoto_data;回调地址后端自动生成),vlphoto_data 由 API 层加密为 data func runVehicleClaimDetailReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { var data types.TocVehicleClaimDetailReq if unmarshalErr := json.Unmarshal(decryptData, &data); unmarshalErr != nil { @@ -348,12 +377,14 @@ func runVehicleClaimDetailReq(l *QueryServiceLogic, decryptData []byte, product if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } } + // 回调地址由后端在 ApiRequestService 中统一生成,此处不再下发 return_url return map[string]interface{}{ "vin_code": data.VinCode, - "return_url": data.ReturnURL, "vlphoto_data": data.VlphotoData, }, nil } @@ -367,12 +398,151 @@ func runVehicleClaimVerifyReq(l *QueryServiceLogic, decryptData []byte, product if validatorErr := validator.Validate(data); validatorErr != nil { return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, validatorErr.Error()), "查询服务, 参数不正确: %+v", validatorErr) } - if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { - return nil, verifyCodeErr + if data.Mobile != "" && data.Code != "" { + if verifyCodeErr := l.VerifyCode(data.Mobile, data.Code); verifyCodeErr != nil { + return nil, verifyCodeErr + } + } + auth := data.Authorized + if auth == "" { + auth = "1" } return map[string]interface{}{ "vin_code": data.VINCode, - "authorized": data.Authorized, + "authorized": auth, + }, nil +} + +// --------------- 核验工具 handlers --------------- +func runVerifyAuthTwoReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyAuthTwoReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"mobile_no": data.MobileNo, "id_card": data.IDCard, "name": data.Name}, nil +} + +func runVerifyAuthThreeReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyAuthThreeReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"photo_data": data.PhotoData, "id_card": data.IDCard, "name": data.Name}, nil +} + +func runVerifyCertReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyCertReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"id_card": data.IDCard, "name": data.Name}, nil +} + +func runVerifyConsumptionReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyConsumptionReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"mobile_no": data.MobileNo, "id_card": data.IDCard, "name": data.Name}, nil +} + +func runVerifyYysTwoReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyYysTwoReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"mobile_no": data.MobileNo, "name": data.Name}, nil +} + +func runVerifyYysThreeReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyYysThreeReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"mobile_no": data.MobileNo, "id_card": data.IDCard, "name": data.Name}, nil +} + +func runVerifyMobileOnlyReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyMobileOnlyReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"mobile_no": data.MobileNo}, nil +} + +func runVerifyYysConsumptionReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyYysConsumptionReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{"mobile_no": data.MobileNo, "authorized": data.Authorized}, nil +} + +func runVerifyEntRelationReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyEntRelationReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + out := map[string]interface{}{} + if data.IDCard != "" { + out["id_card"] = data.IDCard + } + return out, nil +} + +func runVerifyBankFourReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyBankFourReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{ + "mobile_no": data.MobileNo, + "id_card": data.IDCard, + "bank_card": data.BankCard, + "name": data.Name, + }, nil +} + +func runVerifyBankBlackReq(l *QueryServiceLogic, decryptData []byte, product string) (map[string]interface{}, error) { + var data types.TocVerifyBankBlackReq + if err := json.Unmarshal(decryptData, &data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "查询服务, 解密后的数据格式不正确: %+v", err) + } + if err := validator.Validate(data); err != nil { + return nil, errors.Wrapf(xerr.NewErrCodeMsg(xerr.PARAM_VERIFICATION_ERROR, err.Error()), "查询服务, 参数不正确: %+v", err) + } + return map[string]interface{}{ + "mobile_no": data.MobileNo, + "id_card": data.IDCard, + "name": data.Name, + "bank_card": data.BankCard, }, nil } diff --git a/app/main/api/internal/logic/tianyuan/vehiclecallbacklogic.go b/app/main/api/internal/logic/tianyuan/vehiclecallbacklogic.go new file mode 100644 index 0000000..f887ba9 --- /dev/null +++ b/app/main/api/internal/logic/tianyuan/vehiclecallbacklogic.go @@ -0,0 +1,164 @@ +package tianyuan + +import ( + "context" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "time" + + "tyc-server/app/main/api/internal/service" + "tyc-server/app/main/api/internal/svc" + "tyc-server/app/main/model" + "tyc-server/common/xerr" + "tyc-server/pkg/lzkit/crypto" + + "github.com/pkg/errors" + "github.com/zeromicro/go-zero/core/logx" +) + +// VehicleCallbackLogic 处理天远车辆类接口的异步回调 +// 设计目标: +// - 按 order_no + api_id 找到对应的查询记录 +// - 解密原有 query_data([]APIResponseData),更新或追加当前 api_id 的结果 +// - 再次加密写回 query.query_data,必要时将 query_state 从 pending 更新为 success +type VehicleCallbackLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewVehicleCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VehicleCallbackLogic { + return &VehicleCallbackLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +// 与 paySuccessNotify 中一致:仅通过异步回调回写结果的车辆接口 +var asyncVehicleApiIDs = map[string]bool{ + "QCXG1U4U": true, "QCXG3Y6B": true, "QCXG3Z3L": true, "QCXGP00W": true, +} + +// allAsyncVehicleReceived 判断 apiList 中所有“异步车辆接口”是否均已收到回调(Success 为 true) +func allAsyncVehicleReceived(apiList []service.APIResponseData) bool { + for _, item := range apiList { + if asyncVehicleApiIDs[item.ApiID] && !item.Success { + return false + } + } + return true +} + +// Handle 入口:直接接收原始 *http.Request,方便读取 query / body +func (l *VehicleCallbackLogic) Handle(r *http.Request) error { + apiID := r.URL.Query().Get("api_id") + orderNo := r.URL.Query().Get("order_no") + + if apiID == "" || orderNo == "" { + return errors.Wrapf( + xerr.NewErrMsg("缺少 api_id 或 order_no"), + "tianyuan vehicle callback, api_id=%s, order_no=%s", apiID, orderNo, + ) + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "读取回调 Body 失败: %v", err) + } + + // 1. 根据订单号找到订单 + order, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, orderNo) + if err != nil { + if err == model.ErrNotFound { + return errors.Wrapf(xerr.NewErrMsg("未找到订单"), "order_no=%s", orderNo) + } + return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询订单失败, order_no=%s, err=%v", orderNo, err) + } + + // 2. 根据订单ID找到对应查询记录 + query, err := l.svcCtx.QueryModel.FindOneByOrderId(l.ctx, order.Id) + if err != nil { + if err == model.ErrNotFound { + return errors.Wrapf(xerr.NewErrMsg("未找到查询记录"), "order_no=%s, order_id=%d", orderNo, order.Id) + } + return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询 query 记录失败, order_id=%d, err=%v", order.Id, err) + } + + // 3. 获取加密密钥 + secretKey := l.svcCtx.Config.Encrypt.SecretKey + key, decodeErr := hex.DecodeString(secretKey) + if decodeErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "解析 AES 密钥失败: %v", decodeErr) + } + // 4. 解密 query_data,反序列化为 []APIResponseData + var apiList []service.APIResponseData + if query.QueryData.Valid && query.QueryData.String != "" { + decrypted, decErr := crypto.AesDecrypt(query.QueryData.String, key) + if decErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "解密 query_data 失败: %v", decErr) + } + if len(decrypted) > 0 { + if err := json.Unmarshal(decrypted, &apiList); err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "解析 query_data 为 APIResponseData 列表失败: %v", err) + } + } + } + + // 5. 更新或追加当前 api_id 的结果 + nowStr := time.Now().Format("2006-01-02 15:04:05") + updated := false + for i := range apiList { + if apiList[i].ApiID == apiID { + apiList[i].Data = json.RawMessage(bodyBytes) + apiList[i].Success = true + apiList[i].Timestamp = nowStr + apiList[i].Error = "" + updated = true + break + } + } + if !updated { + apiList = append(apiList, service.APIResponseData{ + ApiID: apiID, + Data: json.RawMessage(bodyBytes), + Success: true, + Timestamp: nowStr, + }) + } + + // 6. 重新序列化并加密,写回查询记录 + merged, err := json.Marshal(apiList) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "序列化合并后的 query_data 失败: %v", err) + } + enc, encErr := crypto.AesEncrypt(merged, key) + if encErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "加密合并后的 query_data 失败: %v", encErr) + } + + query.QueryData.String = enc + query.QueryData.Valid = true + // 仅当所有异步车辆接口均已有回调结果时,才将 pending 置为 success,并触发代理结算 + wasPending := query.QueryState == "pending" + didSetSuccess := wasPending && allAsyncVehicleReceived(apiList) + if didSetSuccess { + query.QueryState = "success" + } + + if err := l.svcCtx.QueryModel.UpdateWithVersion(l.ctx, nil, query); err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新查询记录失败, query_id=%d, err=%v", query.Id, err) + } + + if didSetSuccess { + if agentErr := l.svcCtx.AgentService.AgentProcess(l.ctx, order); agentErr != nil { + l.Errorf("tianyuan vehicle callback, AgentProcess failed, order_no=%s, err=%v", orderNo, agentErr) + // 不因代理处理失败而整体失败,回调已成功落库 + } + } + + l.Infof("tianyuan vehicle callback handled, order_no=%s, api_id=%s, query_id=%d", orderNo, apiID, query.Id) + return nil +} diff --git a/app/main/api/internal/logic/upload/serveuploadedfilelogic.go b/app/main/api/internal/logic/upload/serveuploadedfilelogic.go new file mode 100644 index 0000000..d936a46 --- /dev/null +++ b/app/main/api/internal/logic/upload/serveuploadedfilelogic.go @@ -0,0 +1,66 @@ +package upload + +import ( + "context" + "os" + "path/filepath" + "strings" + + "tyc-server/app/main/api/internal/svc" + "tyc-server/app/main/api/internal/types" + "tyc-server/common/xerr" + + "github.com/pkg/errors" + "github.com/zeromicro/go-zero/core/logx" +) + +type ServeUploadedFileLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewServeUploadedFileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ServeUploadedFileLogic { + return &ServeUploadedFileLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ServeUploadedFileLogic) ServeUploadedFile(req *types.ServeUploadedFileReq) (resp *types.ServeUploadedFileResp, err error) { + fileName := strings.TrimSpace(req.FileName) + if fileName == "" { + return nil, errors.Wrap(xerr.NewErrMsg("缺少文件名"), "fileName empty") + } + // 只允许文件名,禁止路径穿越 + if strings.Contains(fileName, "..") || filepath.Base(fileName) != fileName { + return nil, errors.Wrap(xerr.NewErrMsg("非法文件名"), fileName) + } + + candidates := []string{ + "data/uploads", + "../data/uploads", + "../../data/uploads", + "../../../data/uploads", + } + for _, c := range candidates { + abs, _ := filepath.Abs(c) + fullPath := filepath.Join(abs, fileName) + if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { + contentType := "image/jpeg" + if strings.HasSuffix(strings.ToLower(fileName), ".png") { + contentType = "image/png" + } else if strings.HasSuffix(strings.ToLower(fileName), ".gif") { + contentType = "image/gif" + } else if strings.HasSuffix(strings.ToLower(fileName), ".webp") { + contentType = "image/webp" + } + return &types.ServeUploadedFileResp{ + FilePath: fullPath, + ContentType: contentType, + }, nil + } + } + return nil, errors.Wrapf(xerr.NewErrMsg("文件不存在"), "fileName=%s", fileName) +} diff --git a/app/main/api/internal/logic/upload/uploadimagelogic.go b/app/main/api/internal/logic/upload/uploadimagelogic.go new file mode 100644 index 0000000..aa8094e --- /dev/null +++ b/app/main/api/internal/logic/upload/uploadimagelogic.go @@ -0,0 +1,138 @@ +package upload + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "time" + + "tyc-server/app/main/api/internal/svc" + "tyc-server/app/main/api/internal/types" + "tyc-server/common/xerr" + + "github.com/pkg/errors" + "github.com/zeromicro/go-zero/core/logx" +) + +const maxImageSize = 3 * 1024 * 1024 // 3MB +const defaultTempFileMaxAgeH = 24 + +type UploadImageLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUploadImageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadImageLogic { + return &UploadImageLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UploadImageLogic) UploadImage(req *types.UploadImageReq) (resp *types.UploadImageResp, err error) { + decoded, decErr := base64.StdEncoding.DecodeString(req.ImageBase64) + if decErr != nil { + return nil, errors.Wrapf(xerr.NewErrMsg("图片 base64 格式错误"), "%v", decErr) + } + if len(decoded) > maxImageSize { + return nil, errors.Wrapf(xerr.NewErrMsg("图片不能超过 3M"), "size=%d", len(decoded)) + } + + // 按文件内容 hash 命名,相同文件复用同一 URL,避免重复传输与刷流量 + hashSum := sha256.Sum256(decoded) + hashHex := hex.EncodeToString(hashSum[:]) + fileName := hashHex + ".jpg" + + dir := l.uploadStoragePath() + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "创建上传目录失败: %v", err) + } + + filePath := filepath.Join(dir, fileName) + // 若已存在同 hash 文件,直接返回 URL,不重复写入 + if _, statErr := os.Stat(filePath); statErr == nil { + url := l.buildURL(fileName) + logx.Infof("upload image dedup by hash, file=%s", fileName) + return &types.UploadImageResp{Url: url}, nil + } + + if err := os.WriteFile(filePath, decoded, 0644); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "保存图片失败: %v", err) + } + + // 异步清理过期临时文件,不阻塞响应 + go l.deleteOldUploads(dir) + + url := l.buildURL(fileName) + logx.Infof("upload image ok, file=%s", fileName) + return &types.UploadImageResp{Url: url}, nil +} + +func (l *UploadImageLogic) buildURL(fileName string) string { + baseURL := l.svcCtx.Config.Upload.FileBaseURL + if baseURL == "" { + baseURL = l.svcCtx.Config.AdminPromotion.URLDomain + if baseURL != "" { + baseURL = baseURL + "/api/v1/upload/file" + } + } + if baseURL == "" { + return "" + } + return fmt.Sprintf("%s/%s", baseURL, fileName) +} + +func (l *UploadImageLogic) uploadStoragePath() string { + candidates := []string{ + "data/uploads", + "../data/uploads", + "../../data/uploads", + "../../../data/uploads", + } + for _, c := range candidates { + abs, _ := filepath.Abs(c) + if err := os.MkdirAll(abs, 0755); err == nil { + return abs + } + } + abs, _ := filepath.Abs(candidates[0]) + return abs +} + +// deleteOldUploads 删除目录下超过保留时长的临时文件 +func (l *UploadImageLogic) deleteOldUploads(dir string) { + maxAgeH := l.svcCtx.Config.Upload.TempFileMaxAgeH + if maxAgeH <= 0 { + maxAgeH = defaultTempFileMaxAgeH + } + cutoff := time.Now().Add(-time.Duration(maxAgeH) * time.Hour) + + entries, err := os.ReadDir(dir) + if err != nil { + l.Errorf("deleteOldUploads ReadDir: %v", err) + return + } + for _, e := range entries { + if e.IsDir() { + continue + } + path := filepath.Join(dir, e.Name()) + info, err := os.Stat(path) + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + if err := os.Remove(path); err != nil { + l.Errorf("deleteOldUploads Remove %s: %v", path, err) + } else { + l.Infof("deleteOldUploads removed %s", path) + } + } + } +} diff --git a/app/main/api/internal/queue/paySuccessNotify.go b/app/main/api/internal/queue/paySuccessNotify.go index 222dc0c..259b4e6 100644 --- a/app/main/api/internal/queue/paySuccessNotify.go +++ b/app/main/api/internal/queue/paySuccessNotify.go @@ -36,6 +36,26 @@ var payload struct { OrderID int64 `json:"order_id"` } +// 仅通过异步回调回写结果的车辆接口 ApiID +var asyncVehicleApiIDs = map[string]bool{ + "QCXG1U4U": true, // 车辆里程混合 + "QCXG3Y6B": true, // 车辆维保简版 + "QCXG3Z3L": true, // 车辆维保详细版 + "QCXGP00W": true, // 车辆出险详版 +} + +func isAllAsyncVehicleQuery(responseData []service.APIResponseData) bool { + if len(responseData) == 0 { + return false + } + for _, r := range responseData { + if !asyncVehicleApiIDs[r.ApiID] { + return false + } + } + return true +} + func (l *PaySuccessNotifyUserHandler) ProcessTask(ctx context.Context, t *asynq.Task) error { // 从任务的负载中解码数据 if err := json.Unmarshal(t.Payload(), &payload); err != nil { @@ -152,7 +172,8 @@ func (l *PaySuccessNotifyUserHandler) ProcessTask(ctx context.Context, t *asynq. responseData = []service.APIResponseData{} } else { var processErr error - responseData, processErr = l.svcCtx.ApiRequestService.ProcessRequests(decryptData, product.Id) + // 传入订单号,用于在 ApiRequestService 中为异步车辆接口生成回调地址(return_url) + responseData, processErr = l.svcCtx.ApiRequestService.ProcessRequests(decryptData, product.Id, order.OrderNo) if processErr != nil { return l.handleError(ctx, processErr, order, query) } @@ -160,18 +181,16 @@ func (l *PaySuccessNotifyUserHandler) ProcessTask(ctx context.Context, t *asynq. // 计算成功模块的总成本价 totalCostPrice := 0.0 - if responseData != nil { - for _, item := range responseData { - if item.Success { - // 根据API ID查找功能模块 - feature, err := l.svcCtx.FeatureModel.FindOneByApiId(ctx, item.ApiID) - if err != nil { - logx.Errorf("查找功能模块失败, API ID: %s, 错误: %v", item.ApiID, err) - continue - } - // 累加成本价 - totalCostPrice += feature.CostPrice + for _, item := range responseData { + if item.Success { + // 根据API ID查找功能模块 + feature, err := l.svcCtx.FeatureModel.FindOneByApiId(ctx, item.ApiID) + if err != nil { + logx.Errorf("查找功能模块失败, API ID: %s, 错误: %v", item.ApiID, err) + continue } + // 累加成本价 + totalCostPrice += feature.CostPrice } } @@ -204,11 +223,14 @@ func (l *PaySuccessNotifyUserHandler) ProcessTask(ctx context.Context, t *asynq. return l.handleError(ctx, err, order, query) } - query.QueryState = "success" - updateQueryErr := l.svcCtx.QueryModel.UpdateWithVersion(ctx, nil, query) - if updateQueryErr != nil { - updateQueryErr = fmt.Errorf("修改查询状态失败: %v", updateQueryErr) - return l.handleError(ctx, updateQueryErr, order, query) + // 若当前产品全部为异步车辆接口(结果通过回调回写),则保持 pending,由回调再置为 success + if !isAllAsyncVehicleQuery(responseData) { + query.QueryState = "success" + updateQueryErr := l.svcCtx.QueryModel.UpdateWithVersion(ctx, nil, query) + if updateQueryErr != nil { + updateQueryErr = fmt.Errorf("修改查询状态失败: %v", updateQueryErr) + return l.handleError(ctx, updateQueryErr, order, query) + } } err = l.svcCtx.AgentService.AgentProcess(ctx, order) diff --git a/app/main/api/internal/service/apirequestService.go b/app/main/api/internal/service/apirequestService.go index cbfcb7e..ad25329 100644 --- a/app/main/api/internal/service/apirequestService.go +++ b/app/main/api/internal/service/apirequestService.go @@ -60,7 +60,8 @@ type APIResponseData struct { } // ProcessRequests 处理请求 -func (a *ApiRequestService) ProcessRequests(params []byte, productID int64) ([]APIResponseData, error) { +// orderNo: 当前查询对应的订单号,用于为异步车辆类接口生成回调地址(return_url) +func (a *ApiRequestService) ProcessRequests(params []byte, productID int64, orderNo string) ([]APIResponseData, error) { var ctx, cancel = context.WithCancel(context.Background()) defer cancel() build := a.productFeatureModel.SelectBuilder().Where(squirrel.Eq{ @@ -87,6 +88,19 @@ func (a *ApiRequestService) ProcessRequests(params []byte, productID int64) ([]A if len(featureList) == 0 { return nil, errors.New("处理请求错误,产品无对应接口功能") } + + // 在原始 params 上附加 order_no,供异步车辆类接口自动生成回调地址使用 + var baseParams map[string]interface{} + if err := json.Unmarshal(params, &baseParams); err != nil { + return nil, fmt.Errorf("解析查询参数失败: %w", err) + } + if orderNo != "" { + baseParams["order_no"] = orderNo + } + paramsWithOrder, err := json.Marshal(baseParams) + if err != nil { + return nil, fmt.Errorf("序列化查询参数失败: %w", err) + } var ( wg sync.WaitGroup resultsCh = make(chan APIResponseData, len(featureList)) @@ -120,7 +134,7 @@ func (a *ApiRequestService) ProcessRequests(params []byte, productID int64) ([]A tryCount := 0 for { tryCount++ - resp, preprocessErr = a.PreprocessRequestApi(params, feature.ApiId) + resp, preprocessErr = a.PreprocessRequestApi(paramsWithOrder, feature.ApiId) if preprocessErr == nil { break } @@ -213,6 +227,23 @@ var requestProcessors = map[string]func(*ApiRequestService, []byte) ([]byte, err "QCXG3Z3L": (*ApiRequestService).ProcessQCXG3Z3LRequest, "QCXGP00W": (*ApiRequestService).ProcessQCXGP00WRequest, "QCXG6B4E": (*ApiRequestService).ProcessQCXG6B4ERequest, + // 核验工具(verify feature.md) + "IVYZ9K7F": (*ApiRequestService).ProcessIVYZ9K7FRequest, + "IVYZA1B3": (*ApiRequestService).ProcessIVYZA1B3Request, + "IVYZ6M8P": (*ApiRequestService).ProcessIVYZ6M8PRequest, + "JRZQ8B3C": (*ApiRequestService).ProcessJRZQ8B3CRequest, + "YYSY3M8S": (*ApiRequestService).ProcessYYSY3M8SRequest, + "YYSYK9R4": (*ApiRequestService).ProcessYYSYK9R4Request, + "YYSYF2T7": (*ApiRequestService).ProcessYYSYF2T7Request, + "YYSYK8R3": (*ApiRequestService).ProcessYYSYK8R3Request, + "YYSYS9W1": (*ApiRequestService).ProcessYYSYS9W1Request, + "YYSYE7V5": (*ApiRequestService).ProcessYYSYE7V5Request, + "YYSYP0T4": (*ApiRequestService).ProcessYYSYP0T4Request, + "YYSY6F2B": (*ApiRequestService).ProcessYYSY6F2BRequest, + "YYSY9E4A": (*ApiRequestService).ProcessYYSY9E4ARequest, + "QYGL5F6A": (*ApiRequestService).ProcessQYGL5F6ARequest, + "JRZQACAB": (*ApiRequestService).ProcessJRZQACABRequest, + "JRZQ0B6Y": (*ApiRequestService).ProcessJRZQ0B6YRequest, } // PreprocessRequestApi 调用指定的请求处理函数 @@ -1235,10 +1266,12 @@ func (a *ApiRequestService) ProcessQCXG5U0ZRequest(params []byte) ([]byte, error } func (a *ApiRequestService) ProcessQCXG1U4URequest(params []byte) ([]byte, error) { - body := buildVehicleBody(params, []string{"vin_code", "return_url", "image_url"}, nil) - if body["vin_code"] == nil || body["return_url"] == nil || body["image_url"] == nil { - return nil, errors.New("api请求, QCXG1U4U, 缺少必填参数 vin_code/return_url/image_url") + body := buildVehicleBody(params, []string{"vin_code", "image_url"}, nil) + orderNo := gjson.GetBytes(params, "order_no").String() + if body["vin_code"] == nil || body["image_url"] == nil || orderNo == "" { + return nil, errors.New("api请求, QCXG1U4U, 缺少必填参数 vin_code/image_url/order_no") } + body["return_url"] = a.buildVehicleCallbackURL(orderNo, "QCXG1U4U") resp, err := a.tianyuanapi.CallInterface("QCXG1U4U", body) if err != nil { return nil, err @@ -1283,10 +1316,12 @@ func (a *ApiRequestService) ProcessQCXG4I1ZRequest(params []byte) ([]byte, error } func (a *ApiRequestService) ProcessQCXG3Y6BRequest(params []byte) ([]byte, error) { - body := buildVehicleBody(params, []string{"vin_code", "return_url"}, nil) - if body["vin_code"] == nil || body["return_url"] == nil { - return nil, errors.New("api请求, QCXG3Y6B, 缺少必填参数 vin_code/return_url") + body := buildVehicleBody(params, []string{"vin_code"}, nil) + orderNo := gjson.GetBytes(params, "order_no").String() + if body["vin_code"] == nil || orderNo == "" { + return nil, errors.New("api请求, QCXG3Y6B, 缺少必填参数 vin_code/order_no") } + body["return_url"] = a.buildVehicleCallbackURL(orderNo, "QCXG3Y6B") resp, err := a.tianyuanapi.CallInterface("QCXG3Y6B", body) if err != nil { return nil, err @@ -1295,10 +1330,12 @@ func (a *ApiRequestService) ProcessQCXG3Y6BRequest(params []byte) ([]byte, error } func (a *ApiRequestService) ProcessQCXG3Z3LRequest(params []byte) ([]byte, error) { - body := buildVehicleBody(params, []string{"vin_code", "return_url"}, nil) - if body["vin_code"] == nil || body["return_url"] == nil { - return nil, errors.New("api请求, QCXG3Z3L, 缺少必填参数 vin_code/return_url") + body := buildVehicleBody(params, []string{"vin_code"}, nil) + orderNo := gjson.GetBytes(params, "order_no").String() + if body["vin_code"] == nil || orderNo == "" { + return nil, errors.New("api请求, QCXG3Z3L, 缺少必填参数 vin_code/order_no") } + body["return_url"] = a.buildVehicleCallbackURL(orderNo, "QCXG3Z3L") resp, err := a.tianyuanapi.CallInterface("QCXG3Z3L", body) if err != nil { return nil, err @@ -1308,10 +1345,10 @@ func (a *ApiRequestService) ProcessQCXG3Z3LRequest(params []byte) ([]byte, error func (a *ApiRequestService) ProcessQCXGP00WRequest(params []byte) ([]byte, error) { vin := gjson.GetBytes(params, "vin_code") - returnURL := gjson.GetBytes(params, "return_url") + orderNo := gjson.GetBytes(params, "order_no").String() vlphoto := gjson.GetBytes(params, "vlphoto_data") - if !vin.Exists() || vin.String() == "" || !returnURL.Exists() || returnURL.String() == "" || !vlphoto.Exists() || vlphoto.String() == "" { - return nil, errors.New("api请求, QCXGP00W, 缺少必填参数 vin_code/return_url/vlphoto_data") + if !vin.Exists() || vin.String() == "" || orderNo == "" || !vlphoto.Exists() || vlphoto.String() == "" { + return nil, errors.New("api请求, QCXGP00W, 缺少必填参数 vin_code/order_no/vlphoto_data") } key, err := hex.DecodeString(a.config.Encrypt.SecretKey) if err != nil { @@ -1323,7 +1360,7 @@ func (a *ApiRequestService) ProcessQCXGP00WRequest(params []byte) ([]byte, error } resp, err := a.tianyuanapi.CallInterface("QCXGP00W", map[string]interface{}{ "vin_code": vin.String(), - "return_url": returnURL.String(), + "return_url": a.buildVehicleCallbackURL(orderNo, "QCXGP00W"), "data": encData, }) if err != nil { @@ -1334,14 +1371,17 @@ func (a *ApiRequestService) ProcessQCXGP00WRequest(params []byte) ([]byte, error func (a *ApiRequestService) ProcessQCXG6B4ERequest(params []byte) ([]byte, error) { vin := gjson.GetBytes(params, "vin_code") - auth := gjson.GetBytes(params, "authorized") - if !vin.Exists() || vin.String() == "" || !auth.Exists() { - return nil, errors.New("api请求, QCXG6B4E, 缺少 vin_code 或 authorized") + if !vin.Exists() || vin.String() == "" { + return nil, errors.New("api请求, QCXG6B4E, 缺少 vin_code") + } + auth := gjson.GetBytes(params, "authorized").String() + if auth == "" { + auth = "1" } // 天远文档字段名为 VINCode、Authorized resp, err := a.tianyuanapi.CallInterface("QCXG6B4E", map[string]interface{}{ "VINCode": vin.String(), - "Authorized": auth.String(), + "Authorized": auth, }) if err != nil { return nil, err @@ -1349,6 +1389,68 @@ func (a *ApiRequestService) ProcessQCXG6B4ERequest(params []byte) ([]byte, error return convertTianyuanResponse(resp) } +// processVerifyPassThrough 核验类接口:缓存 params 已含 mobile_no/id_card/name 等,原样传天远 +func (a *ApiRequestService) processVerifyPassThrough(params []byte, apiID string) ([]byte, error) { + var m map[string]interface{} + if err := json.Unmarshal(params, &m); err != nil { + return nil, fmt.Errorf("api请求, %s, 解析参数失败: %w", apiID, err) + } + resp, err := a.tianyuanapi.CallInterface(apiID, m) + if err != nil { + return nil, err + } + return convertTianyuanResponse(resp) +} + +func (a *ApiRequestService) ProcessIVYZ9K7FRequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "IVYZ9K7F") +} +func (a *ApiRequestService) ProcessIVYZA1B3Request(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "IVYZA1B3") +} +func (a *ApiRequestService) ProcessIVYZ6M8PRequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "IVYZ6M8P") +} +func (a *ApiRequestService) ProcessJRZQ8B3CRequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "JRZQ8B3C") +} +func (a *ApiRequestService) ProcessYYSY3M8SRequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSY3M8S") +} +func (a *ApiRequestService) ProcessYYSYK9R4Request(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSYK9R4") +} +func (a *ApiRequestService) ProcessYYSYF2T7Request(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSYF2T7") +} +func (a *ApiRequestService) ProcessYYSYK8R3Request(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSYK8R3") +} +func (a *ApiRequestService) ProcessYYSYS9W1Request(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSYS9W1") +} +func (a *ApiRequestService) ProcessYYSYE7V5Request(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSYE7V5") +} +func (a *ApiRequestService) ProcessYYSYP0T4Request(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSYP0T4") +} +func (a *ApiRequestService) ProcessYYSY6F2BRequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSY6F2B") +} +func (a *ApiRequestService) ProcessYYSY9E4ARequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "YYSY9E4A") +} +func (a *ApiRequestService) ProcessQYGL5F6ARequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "QYGL5F6A") +} +func (a *ApiRequestService) ProcessJRZQACABRequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "JRZQACAB") +} +func (a *ApiRequestService) ProcessJRZQ0B6YRequest(params []byte) ([]byte, error) { + return a.processVerifyPassThrough(params, "JRZQ0B6Y") +} + // buildVehicleBody 从 params 中取 required 与 optional 键,仅非空才写入 body func buildVehicleBody(params []byte, required, optional []string) map[string]interface{} { body := make(map[string]interface{}) @@ -1367,6 +1469,18 @@ func buildVehicleBody(params []byte, required, optional []string) map[string]int return body } +// buildVehicleCallbackURL 生成车辆类接口的异步回调地址 +// 当前使用 AdminPromotion.URLDomain 作为域名配置,路径固定为 /api/v1/tianyuan/vehicle/callback +// 并通过查询参数携带 order_no 与 api_id 以便后端识别具体查询与模块。 +func (a *ApiRequestService) buildVehicleCallbackURL(orderNo, apiID string) string { + base := strings.TrimRight(a.config.AdminPromotion.URLDomain, "/") + if base == "" { + // 兜底:如果未配置 URLDomain,则使用相对路径,交给网关/部署层补全域名 + return fmt.Sprintf("/api/v1/tianyuan/vehicle/callback?order_no=%s&api_id=%s", orderNo, apiID) + } + return fmt.Sprintf("%s/api/v1/tianyuan/vehicle/callback?order_no=%s&api_id=%s", base, orderNo, apiID) +} + // ProcessQCXG7A2BRequest 名下车辆 func (a *ApiRequestService) ProcessQCXG7A2BRequest(params []byte) ([]byte, error) { idCard := gjson.GetBytes(params, "id_card") diff --git a/app/main/api/internal/types/query.go b/app/main/api/internal/types/query.go index 1c9e612..7210ce3 100644 --- a/app/main/api/internal/types/query.go +++ b/app/main/api/internal/types/query.go @@ -129,23 +129,24 @@ type TocVehiclesUnderNameCountReq struct { IDCard string `json:"id_card" validate:"required,idCard"` } -// 车辆静态信息/过户详版等 仅 vin_code +// 车辆静态信息/过户详版等 仅 vin_code(车辆类不要求手机号与验证码) type TocVehicleVinCodeReq struct { VinCode string `json:"vin_code" validate:"required"` - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Mobile string `json:"mobile"` + Code string `json:"code"` } // 车辆里程记录(混合) QCXG1U4U type TocVehicleMileageMixedReq struct { - VinCode string `json:"vin_code" validate:"required"` - PlateNo string `json:"plate_no"` - ReturnURL string `json:"return_url" validate:"required"` + VinCode string `json:"vin_code" validate:"required"` + PlateNo string `json:"plate_no"` + // 回调地址由后端在调用第三方接口时自动生成,不再由前端透传 + ReturnURL string `json:"return_url"` ImageURL string `json:"image_url" validate:"required"` RegURL string `json:"reg_url"` EngineNumber string `json:"engine_number"` - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Mobile string `json:"mobile"` + Code string `json:"code"` } // 二手车VIN估值 QCXGY7F2 @@ -155,60 +156,136 @@ type TocVehicleVinValuationReq struct { VehicleLocation string `json:"vehicle_location" validate:"required"` FirstRegistrationDate string `json:"first_registrationdate" validate:"required"` // yyyy-MM Color string `json:"color"` - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Mobile string `json:"mobile"` + Code string `json:"code"` } // 车辆过户简版 QCXG1H7Y type TocVehicleTransferSimpleReq struct { VinCode string `json:"vin_code" validate:"required"` PlateNo string `json:"plate_no"` - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Mobile string `json:"mobile"` + Code string `json:"code"` } // 车辆维保简版 QCXG3Y6B type TocVehicleMaintenanceSimpleReq struct { - VinCode string `json:"vin_code" validate:"required"` - PlateNo string `json:"plate_no"` - ReturnURL string `json:"return_url" validate:"required"` + VinCode string `json:"vin_code" validate:"required"` + PlateNo string `json:"plate_no"` + // 回调地址由后端在调用第三方接口时自动生成,不再由前端透传 + ReturnURL string `json:"return_url"` ImageURL string `json:"image_url"` RegURL string `json:"reg_url"` EngineNumber string `json:"engine_number"` - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Mobile string `json:"mobile"` + Code string `json:"code"` } // 车辆维保详细版 QCXG3Z3L type TocVehicleMaintenanceDetailReq struct { - VinCode string `json:"vin_code" validate:"required"` - PlateNo string `json:"plate_no"` - ReturnURL string `json:"return_url" validate:"required"` + VinCode string `json:"vin_code" validate:"required"` + PlateNo string `json:"plate_no"` + // 回调地址由后端在调用第三方接口时自动生成,不再由前端透传 + ReturnURL string `json:"return_url"` ImageURL string `json:"image_url"` EngineNumber string `json:"engine_number"` - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Mobile string `json:"mobile"` + Code string `json:"code"` } // 车辆出险详版 QCXGP00W,vlphoto_data 加密后以 data 字段提交 type TocVehicleClaimDetailReq struct { - VinCode string `json:"vin_code" validate:"required"` - PlateNo string `json:"plate_no"` - ReturnURL string `json:"return_url" validate:"required"` + VinCode string `json:"vin_code" validate:"required"` + PlateNo string `json:"plate_no"` + // 回调地址由后端在调用第三方接口时自动生成,不再由前端透传 + ReturnURL string `json:"return_url"` VlphotoData string `json:"vlphoto_data" validate:"required"` // 行驶证图片 base64,加密后传 API 的 data - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Mobile string `json:"mobile"` + Code string `json:"code"` } -// 车辆出险记录核验 QCXG6B4E,VINCode + Authorized(0/1) +// 车辆出险记录核验 QCXG6B4E,VINCode;Authorized 由后端默认传 1 type TocVehicleClaimVerifyReq struct { VINCode string `json:"vin_code" validate:"required"` - Authorized string `json:"authorized" validate:"required"` // 0:否 1:是 - Mobile string `json:"mobile" validate:"required,mobile"` - Code string `json:"code" validate:"required"` + Authorized string `json:"authorized"` // 可选,后端默认 1 + Mobile string `json:"mobile"` + Code string `json:"code"` } type AgentIdentifier struct { Product string `json:"product"` AgentID int64 `json:"agent_id"` Price string `json:"price"` } + +// --------------- 核验工具(verify feature.md)--------------- +// 公安二要素 IVYZ9K7F:请求用 mobile,缓存/API 用 mobile_no +type TocVerifyAuthTwoReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` // 前端传 mobile + IDCard string `json:"id_card" validate:"required,idCard"` + Name string `json:"name" validate:"required,name"` +} + +// 公安三要素 IVYZA1B3:photo_data, id_card, name +type TocVerifyAuthThreeReq struct { + PhotoData string `json:"photo_data" validate:"required"` // 人像 base64 + IDCard string `json:"id_card" validate:"required,idCard"` + Name string `json:"name" validate:"required,name"` +} + +// 职业资格证书 IVYZ6M8P:id_card, name +type TocVerifyCertReq struct { + IDCard string `json:"id_card" validate:"required,idCard"` + Name string `json:"name" validate:"required,name"` +} + +// 个人消费能力 JRZQ8B3C +type TocVerifyConsumptionReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` + IDCard string `json:"id_card" validate:"required,idCard"` + Name string `json:"name" validate:"required,name"` +} + +// 运营商二要素 YYSY3M8S +type TocVerifyYysTwoReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` + Name string `json:"name" validate:"required,name"` +} + +// 全网手机三要素 YYSYK9R4 +type TocVerifyYysThreeReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` + IDCard string `json:"id_card" validate:"required,idCard"` + Name string `json:"name" validate:"required,name"` +} + +// 仅手机号:YYSYF2T7/YYSYK8R3/YYSYS9W1/YYSYE7V5/YYSYP0T4/YYSY9E4A +type TocVerifyMobileOnlyReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` +} + +// 手机消费区间 YYSY6F2B +type TocVerifyYysConsumptionReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` + Authorized string `json:"authorized" validate:"required"` // 0/1 +} + +// 名下企业关联 QYGL5F6A:id_card 选填 +type TocVerifyEntRelationReq struct { + IDCard string `json:"id_card"` // 可选 +} + +// 银行卡四要素 JRZQACAB +type TocVerifyBankFourReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` + IDCard string `json:"id_card" validate:"required,idCard"` + BankCard string `json:"bank_card" validate:"required"` + Name string `json:"name" validate:"required,name"` +} + +// 银行卡黑名单 JRZQ0B6Y +type TocVerifyBankBlackReq struct { + MobileNo string `json:"mobile" validate:"required,mobile"` + IDCard string `json:"id_card" validate:"required,idCard"` + Name string `json:"name" validate:"required,name"` + BankCard string `json:"bank_card" validate:"required"` +} diff --git a/app/main/api/internal/types/types.go b/app/main/api/internal/types/types.go index 8604e0f..dd34cb7 100644 --- a/app/main/api/internal/types/types.go +++ b/app/main/api/internal/types/types.go @@ -950,13 +950,13 @@ type AdminUpdateOrderResp struct { } type AdminUpdatePlatformUserReq struct { - Id int64 `path:"id"` // 用户ID - Mobile *string `json:"mobile,optional"` // 手机号 - Password *string `json:"password,optional"` // 密码 - Nickname *string `json:"nickname,optional"` // 昵称 - Info *string `json:"info,optional"` // 备注信息 - Inside *int64 `json:"inside,optional"` // 是否内部用户 1-是 0-否 - Disable *int64 `json:"disable,optional"` // 是否封禁 0-可用 1-禁用 + Id int64 `path:"id"` // 用户ID + Mobile *string `json:"mobile,optional"` // 手机号 + Password *string `json:"password,optional"` // 密码 + Nickname *string `json:"nickname,optional"` // 昵称 + Info *string `json:"info,optional"` // 备注信息 + Inside *int64 `json:"inside,optional"` // 是否内部用户 1-是 0-否 + Disable *int64 `json:"disable,optional"` // 是否封禁 0-可用 1-禁用 } type AdminUpdatePlatformUserResp struct { @@ -2115,6 +2115,15 @@ type SaveAgentMembershipUserConfigReq struct { PriceRatio float64 `json:"price_ratio"` } +type ServeUploadedFileReq struct { + FileName string `path:"fileName"` // 文件名,如 uuid.jpg +} + +type ServeUploadedFileResp struct { + FilePath string `json:"-"` // 内部:本地文件路径 + ContentType string `json:"-"` // 内部:Content-Type +} + type TimeRangeReport struct { Commission float64 `json:"commission"` // 佣金 Report int `json:"report"` // 报告量 @@ -2170,6 +2179,14 @@ type UpdateRoleResp struct { Success bool `json:"success"` // 是否成功 } +type UploadImageReq struct { + ImageBase64 string `json:"image_base64" validate:"required"` // 图片 base64(不含 data URL 前缀) +} + +type UploadImageResp struct { + Url string `json:"url"` // 可公网访问的图片 URL +} + type User struct { Id int64 `json:"id"` Mobile string `json:"mobile"` diff --git a/deploy/sql/product_feature_inserts.sql b/deploy/sql/product_feature_inserts.sql index d1dc280..8623647 100644 --- a/deploy/sql/product_feature_inserts.sql +++ b/deploy/sql/product_feature_inserts.sql @@ -1090,6 +1090,1222 @@ VALUES ( 1 ); +-- ------------------------------ 核验工具(verify feature.md)----------------------------- +-- 公安二要素 IVYZ9K7F +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '公安二要素认证', + 'toc_PoliceTwoFactors', + '
公安二要素
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'IVYZ9K7F', + '公安二要素认证', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'IVYZ9K7F' +WHERE + p.product_en = 'toc_PoliceTwoFactors' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 公安三要素 IVYZA1B3 +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '公安三要素', + 'toc_PoliceThreeFactors', + '公安三要素
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'IVYZA1B3', + '公安三要素', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'IVYZA1B3' +WHERE + p.product_en = 'toc_PoliceThreeFactors' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 职业资格证书 IVYZ6M8P +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '职业资格证书查询', + 'toc_ProfessionalCertificate', + '职业资格证书
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'IVYZ6M8P', + '职业资格证书查询', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'IVYZ6M8P' +WHERE + p.product_en = 'toc_ProfessionalCertificate' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 运营商二要素 YYSY3M8S +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '运营商二要素', + 'toc_OperatorTwoFactors', + '运营商二要素
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSY3M8S', + '运营商二要素', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSY3M8S' +WHERE + p.product_en = 'toc_OperatorTwoFactors' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 全网手机三要素 YYSYK9R4 +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '全网手机三要素验证周更', + 'toc_MobileThreeFactors', + '全网手机三要素
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSYK9R4', + '全网手机三要素验证周更', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSYK9R4' +WHERE + p.product_en = 'toc_MobileThreeFactors' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 号码二次放号 YYSYF2T7 +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '号码二次放号', + 'toc_NumberRecycle', + '号码二次放号
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSYF2T7', + '号码二次放号', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSYF2T7' +WHERE + p.product_en = 'toc_NumberRecycle' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 手机空号检测 YYSYK8R3 +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '手机空号检测', + 'toc_MobileEmptyCheck', + '手机空号检测
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSYK8R3', + '手机空号检测', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSYK8R3' +WHERE + p.product_en = 'toc_MobileEmptyCheck' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 手机携号转网 YYSYS9W1 +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '手机携号转网', + 'toc_MobilePortability', + '手机携号转网
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSYS9W1', + '手机携号转网', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSYS9W1' +WHERE + p.product_en = 'toc_MobilePortability' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 手机在网状态 YYSYE7V5 +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '手机在网状态', + 'toc_MobileOnlineStatus', + '手机在网状态
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSYE7V5', + '手机在网状态', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSYE7V5' +WHERE + p.product_en = 'toc_MobileOnlineStatus' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 手机在网时长 YYSYP0T4 +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '手机号码在网时长', + 'toc_MobileOnlineDuration', + '手机在网时长
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSYP0T4', + '手机号码在网时长', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSYP0T4' +WHERE + p.product_en = 'toc_MobileOnlineDuration' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 手机消费区间 YYSY6F2B +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '手机消费区间验证', + 'toc_MobileConsumptionRange', + '手机消费区间
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSY6F2B', + '手机消费区间验证', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSY6F2B' +WHERE + p.product_en = 'toc_MobileConsumptionRange' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 手机归属地 YYSY9E4A +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '手机号码归属地核验', + 'toc_MobileAttribution', + '手机归属地
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'YYSY9E4A', + '手机号码归属地核验', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'YYSY9E4A' +WHERE + p.product_en = 'toc_MobileAttribution' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 名下企业关联 QYGL5F6A +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '名下企业关联', + 'toc_EnterpriseRelation', + '名下企业关联
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'QYGL5F6A', + '名下企业关联', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'QYGL5F6A' +WHERE + p.product_en = 'toc_EnterpriseRelation' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 银行卡四要素 JRZQACAB +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '银行卡四要素验证(详版)', + 'toc_BankcardFourFactors', + '银行卡四要素
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'JRZQACAB', + '银行卡四要素验证(详版)', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'JRZQACAB' +WHERE + p.product_en = 'toc_BankcardFourFactors' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + +-- 银行卡黑名单 JRZQ0B6Y +INSERT INTO + `product` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `product_name`, + `product_en`, + `description`, + `notes`, + `cost_price`, + `sell_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + '银行卡黑名单(实时)', + 'toc_BankcardBlacklist', + '银行卡黑名单
', + NULL, + 0.50, + 9.90 + ); + +INSERT INTO + `feature` ( + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `api_id`, + `name`, + `cost_price` + ) +VALUES ( + NOW(), + NOW(), + NULL, + 0, + 0, + 'JRZQ0B6Y', + '银行卡黑名单(实时)', + 0.80 + ) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `cost_price` = VALUES(`cost_price`); + +INSERT INTO + `product_feature` ( + `product_id`, + `feature_id`, + `create_time`, + `update_time`, + `delete_time`, + `del_state`, + `version`, + `sort`, + `is_important`, + `enable` + ) +SELECT p.id, f.id, NOW(), NOW(), NULL, 0, 0, 1, 0, 1 +FROM `product` p + JOIN `feature` f ON f.api_id = 'JRZQ0B6Y' +WHERE + p.product_en = 'toc_BankcardBlacklist' + AND NOT EXISTS ( + SELECT 1 + FROM `product_feature` pf + WHERE + pf.product_id = p.id + AND pf.feature_id = f.id + AND pf.del_state = 0 + ); + -- ============================================================================= -- 以后每加一个产品,按上面格式追加三块: -- 1. INSERT INTO product (..., product_name, product_en, ...) diff --git a/deploy/sql/repair_order_refund_q176967208700018143d9f6.sql b/deploy/sql/repair_order_refund_q176967208700018143d9f6.sql new file mode 100644 index 0000000..a956075 --- /dev/null +++ b/deploy/sql/repair_order_refund_q176967208700018143d9f6.sql @@ -0,0 +1,285 @@ +-- ============================================================================= +-- 修复订单 Q_176967208700018143d9f6:支付宝已退款成功,但库内未完成 +-- ============================================================================= +-- +-- 【原因简述】 +-- 后台发起支付宝退款后,支付宝侧已退款成功,但创建退款记录时因 unique_refund_no +-- 冲突(Duplicate entry 'refund-Q_176967208700018143d9f6')导致 createRefundRecordAndUpdateOrder +-- 失败,后续流程未执行:订单未置为 refunded、未写退款记录、未扣代理佣金与钱包。 +-- +-- 【本单实际退款金额】支付宝已退 99.5 元(非整单金额) +-- +-- 【与 adminrefundorderlogic 退款链路对照】 +-- 代码路径:AdminRefundOrder -> handleAlipayRefund -> createRefundRecordAndUpdateOrder + HandleCommissionAndWalletDeduction +-- +-- handleAlipayRefund 成功分支: +-- 1) createRefundRecordAndUpdateOrder(order, req, refundNo, refundResp.TradeNo, ...) +-- -> 事务内:Insert order_refund(refund_no, platform_refund_id=TradeNo, order_id, user_id, product_id, refund_amount, status=success, refund_time=NOW()) +-- -> 事务内:Update order(仅 code 中赋了 status=refunded,未显式设 refund_time/version) +-- 2) HandleCommissionAndWalletDeduction(ctx, svcCtx, nil, order, req.RefundAmount) +-- -> 该订单下 agent_commission:按 refundAmount 比例冲减,refunded_amount 增加,满额则 status=2(UpdateWithVersion) +-- -> 按代理汇总扣减额,agent_wallet 先扣冻结再扣可用(UpdateWithVersion) +-- -> agent_wallet_transaction 插入 type=refund、金额为负的流水 +-- +-- 本 SQL 对应关系: +-- Step 2 = createRefundRecordAndUpdateOrder 的 Insert order_refund(platform_refund_id 修复时无支付宝 TradeNo 填 NULL,可从支付宝补) +-- Step 3 = createRefundRecordAndUpdateOrder 的 Update order,并显式补全 refund_time、version+1 +-- Step 4 = HandleCommissionAndWalletDeduction 对 agent_commission 的冲减(按 99.5 比例) +-- Step 5 = HandleCommissionAndWalletDeduction 对 agent_wallet 扣减 + agent_wallet_transaction 插入 +-- +-- 【执行前请确认】 +-- 1)该订单在支付宝侧已退款成功;2)已备份相关表或先在测试环境执行。 +-- Step 2~5 已包在事务中:任一步报错请执行 ROLLBACK;若 step 1 未查到订单(_order_not_found=1)勿执行后续。 +-- ============================================================================= + +-- 与表字段 collation 一致,避免 #1267 Illegal mix of collations(若表为 utf8mb4_general_ci 则改为 COLLATE utf8mb4_general_ci) +SET + @order_no = CONVERT( + 'Q_176967208700018143d9f6' USING utf8mb4 + ) COLLATE utf8mb4_general_ci; +-- 本单实际退款金额(元) +SET @repair_refund_amount = 99.5; + +-- 1) 取订单信息 +SELECT + id, + user_id, + product_id, + amount, + version INTO @order_id, + @user_id, + @product_id, + @order_amount, + @order_version +FROM `order` +WHERE + order_no = @order_no + AND del_state = 0 +LIMIT 1; + +SET @refund_amount = @repair_refund_amount; + +-- 若无记录则说明订单号错误,终止 +SELECT IF(@order_id IS NULL, 1, 0) AS _order_not_found; +-- 若 _order_not_found=1 请勿继续执行后续语句 + +-- ---------- 以下为事务:任一步报错请执行 ROLLBACK ---------- +START TRANSACTION; + +-- 2) 补写退款记录(若该订单尚无成功状态的退款记录) +-- 代码中 platform_refund_id 来自支付宝 refundResp.TradeNo;修复时无则填 NULL,如有支付宝退款单号可事后 UPDATE 补上 +INSERT INTO + order_refund ( + refund_no, + order_id, + user_id, + product_id, + platform_refund_id, + refund_amount, + refund_reason, + status, + del_state, + version, + refund_time, + close_time, + delete_time + ) +SELECT + CONCAT( + 'refund-', + @order_no, + '-repair' + ), + @order_id, + @user_id, + @product_id, + NULL, + @refund_amount, + NULL, + 'success', + 0, + 0, + NOW(), + NULL, + NULL +FROM ( + SELECT 1 + ) _one +WHERE + NOT EXISTS ( + SELECT 1 + FROM order_refund + WHERE + order_id = @order_id + AND status = 'success' + AND del_state = 0 + ); + +-- 3) 订单状态改为已退款 +UPDATE `order` +SET + status = 'refunded', + refund_time = NOW(), + version = version + 1, + update_time = NOW() +WHERE + id = @order_id + AND del_state = 0 + AND status = 'paid'; + +-- 4) 代理佣金:按本次退款 99.5 元在该订单的佣金上比例冲减(refunded_amount 增加,满额则 status=2) +SET + @total_available = ( + SELECT COALESCE( + SUM(amount - refunded_amount), 0 + ) + FROM agent_commission + WHERE + order_id = @order_id + AND del_state = 0 + ); + +UPDATE agent_commission +SET + refunded_amount = LEAST( + amount, + refunded_amount + @repair_refund_amount * (amount - refunded_amount) / NULLIF(@total_available, 0) + ), + status = CASE + WHEN LEAST( + amount, + refunded_amount + @repair_refund_amount * (amount - refunded_amount) / NULLIF(@total_available, 0) + ) >= amount THEN 2 + ELSE status + END, + version = version + 1, + update_time = NOW() +WHERE + order_id = @order_id + AND del_state = 0 + AND @total_available > 0; + +-- 5) 代理钱包扣减 + 流水(仅对尚未存在本单退款流水的代理扣减,避免重复执行导致重复扣款) +DROP TEMPORARY TABLE IF EXISTS _repair_wallet_snapshot; + +CREATE TEMPORARY TABLE _repair_wallet_snapshot ( + agent_id BIGINT NOT NULL, + balance_before DECIMAL(20, 4) NOT NULL, + frozen_before DECIMAL(20, 4) NOT NULL, + total_deduct DECIMAL(20, 4) NOT NULL, + PRIMARY KEY (agent_id) +); + +-- 按 99.5 元在该订单各代理间按“可退佣金”比例分配扣减额(与 step 4 一致),仅扣减额>0 的代理 +INSERT INTO + _repair_wallet_snapshot ( + agent_id, + balance_before, + frozen_before, + total_deduct + ) +SELECT + agent_id, + balance_before, + frozen_before, + total_deduct +FROM ( + SELECT + c.agent_id, w.balance AS balance_before, w.frozen_balance AS frozen_before, @repair_refund_amount * COALESCE( + SUM(c.amount - c.refunded_amount), 0 + ) / NULLIF(@total_available, 0) AS total_deduct + FROM + agent_commission c + JOIN agent_wallet w ON w.agent_id = c.agent_id + AND w.del_state = 0 + WHERE + c.order_id = @order_id + AND c.del_state = 0 + AND @total_available > 0 + AND NOT EXISTS ( + SELECT 1 + FROM agent_wallet_transaction t + WHERE + t.agent_id = c.agent_id + AND t.transaction_id = @order_no + AND t.transaction_type = 'refund' + AND t.del_state = 0 + ) + GROUP BY + c.agent_id, w.balance, w.frozen_balance + ) _agent_deduct +WHERE + total_deduct > 0; + +-- 从钱包扣减:优先扣冻结余额,不足再扣可用余额 +UPDATE agent_wallet w +INNER JOIN _repair_wallet_snapshot r ON w.agent_id = r.agent_id +SET + w.frozen_balance = w.frozen_balance - LEAST( + r.total_deduct, + w.frozen_balance + ), + w.balance = w.balance - ( + r.total_deduct - LEAST( + r.total_deduct, + w.frozen_balance + ) + ), + w.version = w.version + 1, + w.update_time = NOW() +WHERE + w.del_state = 0; + +-- 插入退款流水(金额为负数) +INSERT INTO + agent_wallet_transaction ( + delete_time, + del_state, + version, + agent_id, + transaction_type, + amount, + balance_before, + balance_after, + frozen_balance_before, + frozen_balance_after, + transaction_id, + related_user_id, + remark + ) +SELECT + NULL, + 0, + 0, + r.agent_id, + 'refund', + - r.total_deduct, + r.balance_before, + r.balance_before - ( + r.total_deduct - LEAST( + r.total_deduct, + r.frozen_before + ) + ), + r.frozen_before, + r.frozen_before - LEAST( + r.total_deduct, + r.frozen_before + ), + @order_no, + NULL, + '订单退款修复(支付宝已退,库内补单)' +FROM _repair_wallet_snapshot r; + +DROP TEMPORARY TABLE IF EXISTS _repair_wallet_snapshot; + +COMMIT; +-- ---------- 事务结束 ---------- + +-- 6) 校验(可选) +-- 订单应为 refunded +-- SELECT id, order_no, status, refund_time FROM `order` WHERE id = @order_id; +-- 应有 success 退款记录,refund_amount = 99.5 +-- SELECT * FROM order_refund WHERE order_id = @order_id AND del_state = 0; +-- 佣金 refunded_amount 按 99.5 比例增加,满额则 status=2 +-- SELECT id, agent_id, order_id, amount, refunded_amount, status FROM agent_commission WHERE order_id = @order_id; \ No newline at end of file