diff --git a/README.md b/README.md index fd3856f..a39f728 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ default: - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) - **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 0~15 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.md) -- **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射;管理接口和数据库均使用明文连接凭据。Adapter 支持成员显式绑定、异步提交、加密回调、权威详情同步、未终态轮询和结果未知时间窗恢复;退款和员工线下代充值均原子创建业务单与审批事实,approved 复用既有资金用例幂等执行,其他终态不产生资金副作用。未知结果只有唯一审批单号候选才关联,绝不盲目重提。详见 [功能总结](docs/wecom-application-connection/功能总结.md) +- **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射,可按已知模板 ID 实时读取控件后配置映射;通讯录按应用可见部门同步,不依赖根部门权限。管理接口和数据库均使用明文连接凭据。Adapter 支持成员显式绑定、异步提交、加密回调、权威详情同步、未终态轮询和结果未知时间窗恢复;退款和员工线下代充值均原子创建业务单与审批事实,approved 复用既有资金用例幂等执行,其他终态不产生资金副作用。未知结果只有唯一审批单号候选才关联,绝不盲目重提。详见 [功能总结](docs/wecom-application-connection/功能总结.md) - **代理钱包桌面扫码充值**:代理可使用微信 Native 或支付宝当面付为当前店铺主钱包在线自充;充值接口通过 `recharge_source` 区分平台线下代充与代理在线自充,支付确认后由 Outbox/Worker 幂等入账,并保留创建支付商户身份供未来对账。详见 [功能总结](docs/feature-034-agent-wallet-qr-recharge/功能总结.md) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 7bbcd96..c76a3cc 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -6591,6 +6591,12 @@ components: description: 总数 type: integer type: object + DtoInspectWeComTemplateParams: + properties: + template_id: + description: 企微后台已创建模板 ID + type: string + type: object DtoInvalidateFailedItem: properties: line: @@ -11082,6 +11088,42 @@ components: description: 本次同步的可见成员数量 type: integer type: object + DtoWeComTemplateControlResponse: + properties: + id: + description: 企微模板控件 ID + type: string + option_keys: + description: 选择控件可用选项 key + items: + type: string + nullable: true + type: array + required: + description: 是否必填 + type: boolean + title: + description: 企微模板控件标题 + type: string + type: + description: 企微模板控件类型 + type: string + type: object + DtoWeComTemplateDetailResponse: + properties: + controls: + description: 可用于业务字段映射的模板控件 + items: + $ref: '#/components/schemas/DtoWeComTemplateControlResponse' + nullable: true + type: array + name: + description: 企微模板名称 + type: string + template_id: + description: 企微模板 ID + type: string + type: object DtoWechatAppIDResponse: properties: app_id: @@ -28633,6 +28675,78 @@ paths: summary: 同步企业微信应用可见成员 tags: - 企业微信审批 + /api/admin/wecom/applications/{id}/templates/inspect: + post: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoInspectWeComTemplateParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComTemplateDetailResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 读取企业微信审批模板控件 + tags: + - 企业微信审批 /api/admin/wecom/applications/{id}/test: post: parameters: diff --git a/docs/wecom-application-connection/功能总结.md b/docs/wecom-application-connection/功能总结.md index 02640cd..d81cbe5 100644 --- a/docs/wecom-application-connection/功能总结.md +++ b/docs/wecom-application-connection/功能总结.md @@ -10,6 +10,7 @@ - `PUT /api/admin/wecom/applications/:id/default-creator`:从应用当前可见成员中选择代理等非企微账号使用的默认审批发起人。 - `POST /api/admin/wecom/applications/:id/members/sync`:拉取应用可见范围内的成员并原子刷新本地选择快照。 - `GET /api/admin/wecom/applications/:id/members`:按姓名或 userid 搜索并分页选择最近同步的可见成员。 +- `POST /api/admin/wecom/applications/:id/templates/inspect`:输入企微后台已知模板 ID,实时读取模板名称、控件 ID、类型、必填标识和选项 key,供保存映射前选择控件。 - `PUT /api/admin/accounts/:id/wecom-binding`:把系统账号绑定到管理员明确选择的 `(corp_id, userid)`,同时保存姓名快照。 - `PUT /api/admin/wecom/scenes/:business_type`:配置已知 `template_id` 和业务字段控件映射,保存前实时读取模板详情校验。 - `GET /api/admin/wecom/scenes`:分页查询退款、员工线下代充值两个稳定业务场景的当前模板映射。 @@ -29,6 +30,7 @@ ## 通讯录与账号绑定 - 同步只读取 `userid`、姓名和部门 ID,不读取手机号或邮箱,也不做手机号、姓名自动匹配。 +- 同步先读取应用可见部门,再从可见部门树的根节点递归拉取成员并按 userid 去重,不要求应用必须有权读取企业根部门。 - `tb_wecom_member` 仅保存应用可见成员的选择快照;同步时把不再可见的成员标记为不可见,不建立本地部门或组织模型。 - 成员 userid 入库前统一转为小写,身份键按 `(corp_id, userid)` 管理;同一企微成员不能同时绑定多个未删除系统账号。 - 账号列表和详情返回 `wecom_corp_id`、`wecom_userid`、`wecom_name` 和 `wecom_bound`。 @@ -47,6 +49,7 @@ ## 审批场景与模板映射 +- 企微没有按应用枚举全部审批模板的接口;管理员需先从企微后台取得模板 ID,再调用模板检查接口读取可映射控件。 - 稳定业务类型固定为 `refund_approval` 和 `offline_recharge_approval`,业务代码不直接写死企微模板 ID。 - 模板必须先在企业微信后台创建;本系统不创建模板,也不保存审批节点或审批人规则。 - 保存映射时调用模板详情,逐项校验控件 ID、控件类型和选择项 key,并要求模板所有必填控件都有映射;模板结构已变化时拒绝覆盖当前有效配置。 diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go index 435a8b2..6a2b679 100644 --- a/internal/application/wecom/scene.go +++ b/internal/application/wecom/scene.go @@ -62,6 +62,34 @@ func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepositor return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now} } +// InspectTemplate 实时读取企微模板详情,供管理员配置控件映射。 +func (s *SceneService) InspectTemplate(ctx context.Context, applicationID uint, request dto.InspectWeComTemplateRequest) (*dto.WeComTemplateDetailResponse, error) { + if s == nil || s.provider == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置") + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return nil, errors.New(errors.CodeForbidden) + } + templateID := strings.TrimSpace(request.TemplateID) + if applicationID == 0 || templateID == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + definition, err := s.provider.GetTemplateDetail(ctx, applicationID, templateID) + if err != nil { + return nil, err + } + controls := make([]dto.WeComTemplateControlResponse, 0, len(definition.Controls)) + for _, control := range definition.Controls { + controls = append(controls, dto.WeComTemplateControlResponse{ + ID: control.ID, Type: control.Type, Title: control.Title, + Required: control.Required, OptionKeys: control.OptionKeys, + }) + } + return &dto.WeComTemplateDetailResponse{ + TemplateID: definition.TemplateID, Name: definition.Name, Controls: controls, + }, nil +} + // Save 校验模板控件后创建或替换指定稳定业务场景映射。 func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) { if s == nil || s.db == nil || s.provider == nil || s.repo == nil { diff --git a/internal/handler/admin/wecom.go b/internal/handler/admin/wecom.go index 8f54d37..a80cf79 100644 --- a/internal/handler/admin/wecom.go +++ b/internal/handler/admin/wecom.go @@ -156,6 +156,30 @@ func (h *WeComHandler) ListMembers(c *fiber.Ctx) error { return response.Success(c, result) } +// InspectTemplate 实时读取企业微信审批模板控件。 +// POST /api/admin/wecom/applications/:id/templates/inspect +func (h *WeComHandler) InspectTemplate(c *fiber.Ctx) error { + if h == nil || h.scenes == nil || h.validator == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效") + } + var request dto.InspectWeComTemplateRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.scenes.InspectTemplate(c.UserContext(), uint(id), request) + if err != nil { + return err + } + return response.Success(c, result) +} + // SaveScene 保存并校验稳定业务类型的企微模板控件映射。 // PUT /api/admin/wecom/scenes/:business_type func (h *WeComHandler) SaveScene(c *fiber.Ctx) error { diff --git a/internal/infrastructure/wecom/directory_client.go b/internal/infrastructure/wecom/directory_client.go index 42fc88d..4455853 100644 --- a/internal/infrastructure/wecom/directory_client.go +++ b/internal/infrastructure/wecom/directory_client.go @@ -43,7 +43,7 @@ func NewDirectoryClient(tokens DirectoryTokenProvider, integration TokenIntegrat } } -// ListVisibleMembers 拉取根部门及其子部门中当前应用可见的成员。 +// ListVisibleMembers 按应用当前可见部门拉取成员。 func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID uint) ([]wecomapp.DirectoryMember, error) { if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil || applicationID == 0 { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置") @@ -52,7 +52,64 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID if err != nil { return nil, err } - request, err := c.newListRequest(ctx, token) + departments, err := c.listVisibleDepartments(ctx, applicationID, token) + if err != nil { + return nil, err + } + departmentIDs := visibleDepartmentRoots(departments) + remoteMembers := make([]directoryMember, 0) + for _, departmentID := range departmentIDs { + members, err := c.listDepartmentMembers(ctx, applicationID, token, departmentID) + if err != nil { + return nil, err + } + remoteMembers = append(remoteMembers, members...) + } + return normalizeRemoteMembers(remoteMembers), nil +} + +func (c *DirectoryClient) listVisibleDepartments(ctx context.Context, applicationID uint, token string) ([]directoryDepartment, error) { + request, err := c.newDepartmentListRequest(ctx, token) + if err != nil { + return nil, err + } + resourceID := strconv.FormatUint(uint64(applicationID), 10) + requestID := middleware.GetRequestIDFromContext(ctx) + attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComVisibleDepartments, ResourceType: constants.WeComApplicationResourceType, + ResourceID: &resourceID, RequestSummary: map[string]any{"application_id": applicationID}, + RequestID: requestID, CorrelationID: requestID, + }) + if err != nil { + return nil, err + } + startedAt := c.now() + response, err := c.httpClient.Do(request) + if err != nil { + return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt) + } + defer response.Body.Close() + var result departmentResponse + if err := c.readResponse(response, &result); err != nil { + return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信通讯录响应无效", startedAt) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 { + providerCode := strconv.FormatInt(result.ErrCode, 10) + if result.ErrCode == 0 { + providerCode = strconv.Itoa(response.StatusCode) + } + return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, result.ErrMsg, startedAt) + } + if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result.ErrCode, result.ErrMsg, + map[string]any{"errcode": result.ErrCode, "department_count": len(result.Departments)}, startedAt); err != nil { + return nil, err + } + return result.Departments, nil +} + +func (c *DirectoryClient) listDepartmentMembers(ctx context.Context, applicationID uint, token string, departmentID int64) ([]directoryMember, error) { + request, err := c.newMemberListRequest(ctx, token, departmentID) if err != nil { return nil, err } @@ -62,7 +119,7 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, Operation: constants.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType, ResourceID: &resourceID, RequestSummary: map[string]any{ - "application_id": applicationID, "department_id": constants.WeComRootDepartmentID, "fetch_child": true, + "application_id": applicationID, "department_id": departmentID, "fetch_child": true, }, RequestID: requestID, CorrelationID: requestID, }) if err != nil { @@ -74,8 +131,8 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt) } defer response.Body.Close() - result, err := c.readResponse(response) - if err != nil { + var result directoryResponse + if err := c.readResponse(response, &result); err != nil { return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信通讯录响应无效", startedAt) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 { @@ -85,21 +142,36 @@ func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID } return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, result.ErrMsg, startedAt) } - members := normalizeRemoteMembers(result.UserList) - if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result, len(members), startedAt); err != nil { + if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result.ErrCode, result.ErrMsg, + map[string]any{"errcode": result.ErrCode, "member_count": len(result.UserList)}, startedAt); err != nil { return nil, err } - return members, nil + return result.UserList, nil } -func (c *DirectoryClient) newListRequest(ctx context.Context, token string) (*http.Request, error) { +func (c *DirectoryClient) newDepartmentListRequest(ctx context.Context, token string) (*http.Request, error) { + endpoint, err := url.Parse(c.baseURL + "/cgi-bin/department/list") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("access_token", token) + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信部门请求失败") + } + return request, nil +} + +func (c *DirectoryClient) newMemberListRequest(ctx context.Context, token string, departmentID int64) (*http.Request, error) { endpoint, err := url.Parse(c.baseURL + "/cgi-bin/user/simplelist") if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") } query := endpoint.Query() query.Set("access_token", token) - query.Set("department_id", strconv.FormatInt(constants.WeComRootDepartmentID, 10)) + query.Set("department_id", strconv.FormatInt(departmentID, 10)) query.Set("fetch_child", "1") endpoint.RawQuery = query.Encode() request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) @@ -115,25 +187,54 @@ type directoryResponse struct { UserList []directoryMember `json:"userlist"` } +type departmentResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + Departments []directoryDepartment `json:"department"` +} + +type directoryDepartment struct { + ID int64 `json:"id"` + ParentID int64 `json:"parentid"` +} + type directoryMember struct { UserID string `json:"userid"` Name string `json:"name"` Department []int64 `json:"department"` } -func (c *DirectoryClient) readResponse(response *http.Response) (directoryResponse, error) { - var result directoryResponse +func (c *DirectoryClient) readResponse(response *http.Response, result any) error { body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1)) if err != nil { - return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败") + return errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败") } if int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes { - return result, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大") + return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大") } - if err := sonic.Unmarshal(body, &result); err != nil { - return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败") + if err := sonic.Unmarshal(body, result); err != nil { + return errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败") } - return result, nil + return nil +} + +func visibleDepartmentRoots(departments []directoryDepartment) []int64 { + visible := make(map[int64]struct{}, len(departments)) + for _, department := range departments { + if department.ID > 0 { + visible[department.ID] = struct{}{} + } + } + result := make([]int64, 0, len(departments)) + for _, department := range departments { + if department.ID <= 0 { + continue + } + if _, parentVisible := visible[department.ParentID]; department.ParentID <= 0 || department.ParentID == department.ID || !parentVisible { + result = append(result, department.ID) + } + } + return result } func normalizeRemoteMembers(source []directoryMember) []wecomapp.DirectoryMember { @@ -157,12 +258,11 @@ func normalizeRemoteMembers(source []directoryMember) []wecomapp.DirectoryMember return result } -func (c *DirectoryClient) completeSuccess(ctx context.Context, integrationID string, status int, result directoryResponse, memberCount int, startedAt time.Time) error { +func (c *DirectoryClient) completeSuccess(ctx context.Context, integrationID string, status int, providerCode int64, providerMessage string, responseSummary map[string]any, startedAt time.Time) error { _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ Result: constants.IntegrationResultSuccess, HTTPStatus: status, - ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg, - ResponseSummary: map[string]any{"errcode": result.ErrCode, "member_count": memberCount}, - DurationMS: c.now().Sub(startedAt).Milliseconds(), + ProviderCode: strconv.FormatInt(providerCode, 10), ProviderMessage: providerMessage, + ResponseSummary: responseSummary, DurationMS: c.now().Sub(startedAt).Milliseconds(), }) return err } diff --git a/internal/model/dto/wecom_scene_dto.go b/internal/model/dto/wecom_scene_dto.go index b0267ce..190b5a7 100644 --- a/internal/model/dto/wecom_scene_dto.go +++ b/internal/model/dto/wecom_scene_dto.go @@ -10,6 +10,33 @@ type WeComControlMappingItem struct { OptionMapping map[string]string `json:"option_mapping" description:"业务枚举值到企微选择项 key 的映射;非选择控件传空对象"` } +// InspectWeComTemplateRequest 检查企业微信审批模板请求。 +type InspectWeComTemplateRequest struct { + TemplateID string `json:"template_id" validate:"required,min=1,max=128" description:"企微后台已创建模板 ID"` +} + +// InspectWeComTemplateParams 检查企业微信审批模板路径与请求参数。 +type InspectWeComTemplateParams struct { + IDReq + InspectWeComTemplateRequest +} + +// WeComTemplateControlResponse 企业微信审批模板控件响应。 +type WeComTemplateControlResponse struct { + ID string `json:"id" description:"企微模板控件 ID"` + Type string `json:"type" description:"企微模板控件类型"` + Title string `json:"title" description:"企微模板控件标题"` + Required bool `json:"required" description:"是否必填"` + OptionKeys []string `json:"option_keys" description:"选择控件可用选项 key"` +} + +// WeComTemplateDetailResponse 企业微信审批模板详情响应。 +type WeComTemplateDetailResponse struct { + TemplateID string `json:"template_id" description:"企微模板 ID"` + Name string `json:"name" description:"企微模板名称"` + Controls []WeComTemplateControlResponse `json:"controls" description:"可用于业务字段映射的模板控件"` +} + // SaveWeComApprovalSceneRequest 保存企业微信审批场景请求。 type SaveWeComApprovalSceneRequest struct { ApplicationID uint `json:"application_id" validate:"required,gt=0" description:"企业微信应用配置 ID"` diff --git a/internal/routes/wecom.go b/internal/routes/wecom.go index 6af184d..27a7a93 100644 --- a/internal/routes/wecom.go +++ b/internal/routes/wecom.go @@ -63,6 +63,13 @@ func registerWeComRoutes(router fiber.Router, handler *admin.WeComHandler, doc * Output: new(dto.WeComMemberListResponse), Auth: true, }) + Register(group, doc, groupPath, "POST", "/applications/:id/templates/inspect", handler.InspectTemplate, RouteSpec{ + Summary: "读取企业微信审批模板控件", + Tags: []string{"企业微信审批"}, + Input: new(dto.InspectWeComTemplateParams), + Output: new(dto.WeComTemplateDetailResponse), + Auth: true, + }) Register(group, doc, groupPath, "PUT", "/scenes/:business_type", handler.SaveScene, RouteSpec{ Summary: "保存并校验企业微信审批场景模板映射", Tags: []string{"企业微信审批"}, diff --git a/openspec/changes/add-agent-wallet-qr-recharge/.openspec.yaml b/openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/.openspec.yaml similarity index 100% rename from openspec/changes/add-agent-wallet-qr-recharge/.openspec.yaml rename to openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/.openspec.yaml diff --git a/openspec/changes/add-agent-wallet-qr-recharge/design.md b/openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/design.md similarity index 100% rename from openspec/changes/add-agent-wallet-qr-recharge/design.md rename to openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/design.md diff --git a/openspec/changes/add-agent-wallet-qr-recharge/proposal.md b/openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/proposal.md similarity index 100% rename from openspec/changes/add-agent-wallet-qr-recharge/proposal.md rename to openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/proposal.md diff --git a/openspec/changes/add-agent-wallet-qr-recharge/specs/agent-recharge/spec.md b/openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/specs/agent-recharge/spec.md similarity index 100% rename from openspec/changes/add-agent-wallet-qr-recharge/specs/agent-recharge/spec.md rename to openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/specs/agent-recharge/spec.md diff --git a/openspec/changes/add-agent-wallet-qr-recharge/tasks.md b/openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/tasks.md similarity index 100% rename from openspec/changes/add-agent-wallet-qr-recharge/tasks.md rename to openspec/changes/archive/2026-07-27-add-agent-wallet-qr-recharge/tasks.md diff --git a/openspec/specs/agent-recharge/spec.md b/openspec/specs/agent-recharge/spec.md index f092f5c..25c8fd6 100644 --- a/openspec/specs/agent-recharge/spec.md +++ b/openspec/specs/agent-recharge/spec.md @@ -1,153 +1,67 @@ # 代理充值管理 API 规范 -## ADDED Requirements +## Purpose + +定义代理钱包在线扫码自充、平台线下代充、第三方支付确认、异步钱包入账、状态查询、异常恢复与多租户权限控制的统一业务契约,确保充值资金事实可核对、可恢复且不会重复入账。 + +## Requirements --- ### Requirement: 创建代理充值订单 -**接口描述**:代理或平台账号发起代理余额钱包充值,创建充值订单。 +系统 SHALL 通过 `POST /api/admin/agent-recharges` 保留同一代理充值资源,并按登录账号类型与 `payment_method` 执行严格分流。 -**HTTP 方法与路径** +代理在线请求 MUST 仅接受 `amount`、`payment_method` 与 `request_id`: -``` -POST /api/admin/agent-recharges -``` +- `payment_method` MUST 为 `wechat` 或 `alipay`。 +- `amount` MUST 使用分为单位,范围 MUST 为 `10000~100000000`。 +- 目标店铺与主钱包 MUST 从当前认证上下文确定;请求 MUST NOT 接受或信任 `shop_id`、支付凭证或运营备注。 +- 每次新的主动提交 MUST 创建新的代理充值单和 `tb_payment` 支付单,不得按金额或待支付记录复用旧单。 +- 同一提交账号与 `request_id` MUST 形成持久化幂等作用域;相同指纹重放 MUST 返回原业务结果,不同指纹重放 MUST 返回统一冲突错误。 -**鉴权** +平台或超级管理员的 `offline` 线下代充 MUST 继续使用现有目标 `shop_id`、付款凭证和企业微信审批契约,在线充值 100 元最低金额 MUST NOT 改变线下代充金额规则。平台、超级管理员和企业账号 MUST NOT 创建代理在线扫码充值单。 -- 需要登录态(Bearer Token) -- 代理账号:只能为自己所属店铺的主钱包(wallet_type=main)充值 -- 平台账号:可指定任意店铺 +在线创建 MUST 在同一 GORM 事务中保存充值单、支付单及请求幂等事实,再调用创建时选定的支付 Adapter:微信 MUST 使用 Native 支付,支付宝 MUST 使用 `alipay.trade.precreate`。成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并在 `data` 中至少返回 `recharge_id`、`recharge_no`、`payment_no`、`payment_method`、`recharge_source`、`recharge_source_name`、`amount`、`qr_content`、`status` 与 `status_name`。后端 MUST 返回支付渠道原始付款字符串,不得生成或保存二维码图片。 ---- +充值订单创建、列表、详情和在线支付状态响应 MUST 使用稳定来源枚举区分创建路径:`platform_offline` 表示平台线下代充,`agent_online` 表示代理在线自充。列表 MUST 支持使用 `recharge_source` 筛选;来源可由受控创建方式推导,不要求新增重复数据库字段。 -**请求体示例(在线充值 - 微信)** +支付单 MUST 同时保存创建时的收款身份快照:微信记录商户号,支付宝记录应用 ID,并保留支付方式与 `payment_config_id`,供后续导出对账。创建响应 MUST NOT 返回该内部收款身份快照。 -```json -{ - "shop_id": 101, - "amount": 50000, - "payment_method": "wechat" -} -``` +第三方预下单失败时,系统 MUST 记录 Integration Log,并将本次支付单标记为失败、充值单标记为已关闭;不得返回缺少有效 `qr_content` 的成功响应。 -**请求体示例(线下充值 - 仅平台)** +#### Scenario: 代理创建微信 Native 扫码充值 +- **WHEN** 代理提交 `amount=10000`、`payment_method=wechat` 和新的 `request_id` +- **THEN** 系统从登录上下文确定当前店铺主钱包,创建充值单和支付单,调用微信 Native 预下单,并将 `code_url` 映射为 `qr_content` +- **THEN** 响应不得包含支付配置 ID、商户密钥或其他店铺信息 -```json -{ - "shop_id": 101, - "amount": 200000, - "payment_method": "offline" -} -``` +#### Scenario: 代理创建支付宝当面付扫码充值 +- **WHEN** 代理提交有效金额、`payment_method=alipay` 和新的 `request_id` +- **THEN** 系统创建充值单和支付单,调用 `alipay.trade.precreate`,并将 `qr_code` 映射为 `qr_content` -**请求字段说明** +#### Scenario: 区分平台代充与代理自充 +- **WHEN** 调用方查询充值订单列表、详情或在线支付状态 +- **THEN** 平台线下代充 MUST 返回 `recharge_source=platform_offline`,代理微信或支付宝在线自充 MUST 返回 `recharge_source=agent_online` -| 字段名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| shop_id | integer | 是 | 目标店铺 ID。代理账号只能填写自己所属店铺 ID | -| amount | integer | 是 | 充值金额(单位:分)。范围:1~100000000(即 1 分~100 万元) | -| payment_method | string | 是 | 支付方式。可选值:`wechat`(在线微信支付)、`offline`(线下转账,仅平台可用) | +#### Scenario: 在线充值金额边界 +- **WHEN** 代理提交的在线充值金额小于 `10000` 分或大于 `100000000` 分 +- **THEN** 系统 MUST 返回 `CodeInvalidParam`,且不得创建充值单、支付单或调用第三方支付 -**业务规则** +#### Scenario: 代理请求携带目标店铺 +- **WHEN** 代理在线请求携带 `shop_id` 或其他仅线下代充允许的字段 +- **THEN** 系统 MUST 拒绝请求,不得允许代理选择本店、下级店铺或其他店铺作为受益方 -- `amount` 最小值为 `AgentRechargeMinAmount`(1 分),最大值为 `AgentRechargeMaxAmount`(100000000 分 = 100 万元) -- `payment_method=wechat` 时,系统根据当前激活的支付配置自动路由至微信直连或富友通道,并记录 `payment_config_id`;客户端发起支付的具体流程本期暂不实现(Stub) -- `payment_method=offline` 仅平台账号可使用,代理账号调用此方式将返回 `1005 CodeForbidden` -- 订单创建后状态为 `1`(待支付) -- 充值单号前缀为 `ARCH`,全局唯一 +#### Scenario: 相同请求重放 +- **WHEN** 同一提交账号使用相同 `request_id` 和相同业务字段重试 +- **THEN** 系统 MUST 返回首次创建的充值单、支付单和付款内容,不得再次创建业务单或再次向第三方预下单 ---- +#### Scenario: 幂等请求载荷冲突 +- **WHEN** 同一提交账号使用已有 `request_id` 但改变金额或支付方式 +- **THEN** 系统 MUST 返回 `CodeConflict`,不得改变原充值单或创建新单 -**成功响应示例** - -```json -{ - "code": 0, - "msg": "success", - "data": { - "id": 88, - "recharge_no": "ARCH20260316100001", - "shop_id": 101, - "amount": 50000, - "payment_method": "wechat", - "payment_channel": "wechat_direct", - "payment_config_id": 3, - "status": 1, - "created_at": "2026-03-16T10:00:00+08:00" - }, - "timestamp": "2026-03-16T10:00:00+08:00" -} -``` - -**响应字段说明** - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | integer | 充值记录 ID | -| recharge_no | string | 充值单号(ARCH 前缀) | -| shop_id | integer | 店铺 ID | -| amount | integer | 充值金额(分) | -| payment_method | string | 支付方式 | -| payment_channel | string | 实际支付通道(wechat_direct / fuyou / offline) | -| payment_config_id | integer\|null | 关联的支付配置 ID(线下充值为 null) | -| status | integer | 订单状态:1=待支付,2=已完成,3=已取消 | -| created_at | string | 创建时间(RFC3339) | - ---- - -**错误响应示例** - -金额超出范围: -```json -{ - "code": 1001, - "msg": "充值金额超出允许范围(1分~100万元)", - "data": null, - "timestamp": "2026-03-16T10:00:00+08:00" -} -``` - -代理账号使用线下充值: -```json -{ - "code": 1005, - "msg": "只有平台账号可以使用线下充值", - "data": null, - "timestamp": "2026-03-16T10:00:00+08:00" -} -``` - -钱包不存在: -```json -{ - "code": 1053, - "msg": "钱包不存在", - "data": null, - "timestamp": "2026-03-16T10:00:00+08:00" -} -``` - -无可用支付配置: -```json -{ - "code": 1175, - "msg": "当前无可用的支付配置,请联系管理员", - "data": null, - "timestamp": "2026-03-16T10:00:00+08:00" -} -``` - -越权访问(代理操作他人店铺): -```json -{ - "code": 1005, - "msg": "无权限操作该资源或资源不存在", - "data": null, - "timestamp": "2026-03-16T10:00:00+08:00" -} -``` +#### Scenario: 支付方式不可用 +- **WHEN** 所选支付方式缺少完整配置、扫码预下单能力或回调验签能力 +- **THEN** 系统 MUST 返回统一支付配置不可用错误,且不得创建只有本地记录而无法付款的待支付订单 --- @@ -155,6 +69,8 @@ POST /api/admin/agent-recharges **接口描述**:平台账号确认线下转账已到账,完成充值并为代理钱包增加余额。 +系统 SHALL 仅允许平台账号对符合条件的存量线下充值记录执行确认,并在校验操作密码后完成一次性钱包入账。 + **HTTP 方法与路径** ``` @@ -268,13 +184,19 @@ POST /api/admin/agent-recharges/:id/offline-pay } ``` +#### Scenario: 平台确认存量线下充值 +- **WHEN** 平台账号对待支付的线下充值记录提交正确操作密码 +- **THEN** 系统 MUST 完成充值记录、主钱包余额和唯一钱包流水更新,重复操作不得重复入账 + --- ### Requirement: 代理充值查询 +系统 SHALL 按当前账号数据范围提供代理充值列表与详情查询,并返回稳定的充值来源、状态和时间字段。 + #### 接口一:充值记录列表 -**接口描述**:分页查询代理充值记录,支持按店铺、状态、日期范围过滤。 +**接口描述**:分页查询代理充值记录,支持按店铺、状态、充值来源、日期范围过滤。 **HTTP 方法与路径** @@ -293,7 +215,7 @@ GET /api/admin/agent-recharges **请求参数(Query String)** ``` -GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=2&start_date=2026-03-01&end_date=2026-03-31 +GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=3&recharge_source=agent_online&start_date=2026-03-01&end_date=2026-03-31 ``` **请求参数说明** @@ -303,7 +225,8 @@ GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=2&start_da | page | integer | 否 | 页码,默认 1 | | page_size | integer | 否 | 每页条数,默认 20,最大 100 | | shop_id | integer | 否 | 按店铺 ID 过滤(平台账号可用) | -| status | integer | 否 | 按状态过滤:1=待支付,2=已完成,3=已取消 | +| status | integer | 否 | 按状态过滤:1=待支付,2=已支付,3=已完成,4=已关闭 | +| recharge_source | string | 否 | 按充值来源过滤:`platform_offline`=平台线下代充,`agent_online`=代理在线自充 | | start_date | string | 否 | 创建时间起始日期,格式 `YYYY-MM-DD` | | end_date | string | 否 | 创建时间截止日期,格式 `YYYY-MM-DD` | @@ -329,7 +252,9 @@ GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=2&start_da "payment_method": "wechat", "payment_channel": "wechat_direct", "payment_config_id": 3, - "status": 2, + "recharge_source": "agent_online", + "recharge_source_name": "代理在线自充", + "status": 3, "paid_at": "2026-03-16T10:05:00+08:00", "completed_at": "2026-03-16T10:05:00+08:00", "created_at": "2026-03-16T10:00:00+08:00" @@ -343,7 +268,9 @@ GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=2&start_da "payment_method": "offline", "payment_channel": "offline", "payment_config_id": null, - "status": 2, + "recharge_source": "platform_offline", + "recharge_source_name": "平台线下代充", + "status": 3, "paid_at": "2026-03-15T11:00:00+08:00", "completed_at": "2026-03-15T11:00:00+08:00", "created_at": "2026-03-15T09:00:00+08:00" @@ -366,7 +293,9 @@ GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=2&start_da | payment_method | string | 支付方式 | | payment_channel | string | 实际支付通道 | | payment_config_id | integer\|null | 关联支付配置 ID | -| status | integer | 状态:1=待支付,2=已完成,3=已取消 | +| recharge_source | string | 充值来源:`platform_offline` 或 `agent_online` | +| recharge_source_name | string | 充值来源中文名称 | +| status | integer | 状态:1=待支付,2=已支付,3=已完成,4=已关闭 | | paid_at | string\|null | 支付时间 | | completed_at | string\|null | 完成时间 | | created_at | string | 创建时间 | @@ -430,7 +359,9 @@ GET /api/admin/agent-recharges/:id "payment_channel": "wechat_direct", "payment_config_id": 3, "payment_transaction_id": "wx_txn_20260316_abc123", - "status": 2, + "recharge_source": "agent_online", + "recharge_source_name": "代理在线自充", + "status": 3, "paid_at": "2026-03-16T10:05:00+08:00", "completed_at": "2026-03-16T10:05:00+08:00", "created_at": "2026-03-16T10:00:00+08:00", @@ -454,7 +385,9 @@ GET /api/admin/agent-recharges/:id | payment_channel | string | 实际支付通道 | | payment_config_id | integer\|null | 关联支付配置 ID | | payment_transaction_id | string\|null | 第三方支付流水号 | -| status | integer | 状态:1=待支付,2=已完成,3=已取消 | +| recharge_source | string | 充值来源:`platform_offline` 或 `agent_online` | +| recharge_source_name | string | 充值来源中文名称 | +| status | integer | 状态:1=待支付,2=已支付,3=已完成,4=已关闭 | | paid_at | string\|null | 支付时间 | | completed_at | string\|null | 完成时间 | | created_at | string | 创建时间 | @@ -474,95 +407,139 @@ GET /api/admin/agent-recharges/:id } ``` +#### Scenario: 按数据范围查询充值记录 +- **WHEN** 已认证账号查询充值列表或详情 +- **THEN** 系统 MUST 仅返回其数据范围内的记录,并使用 `recharge_source` 区分平台线下代充与代理在线自充 + --- ### Requirement: 代理充值回调处理 -**接口描述**:接收第三方支付平台(微信直连 / 富友)的异步支付结果通知,完成充值订单状态更新和钱包余额增加。 +现有微信和支付宝异步回调入口 MUST 在完成渠道验签后,按 `tb_payment.payment_no` 和 `order_type=agent_recharge` 分发到代理充值支付确认用例,不得仅依赖 `ARCH` 单号前缀判断业务类型。旧代理充值支付单可在迁移期保留受控兼容分支,但新支付单 MUST 走统一支付记录分发。 -**HTTP 方法与路径** +支付确认 MUST 校验支付方式、创建时的 `payment_config_id`、回调商户或应用身份、支付单金额、充值单金额、支付单与充值单关联以及第三方交易号唯一性。校验失败 MUST 不改变支付单、充值单或钱包事实,并写入中文安全日志和 Integration Log;日志不得记录密钥或完整敏感正文。 -回调地址由支付配置中的 `notify_url` 字段决定,格式示例: +成功确认 MUST 在同一 GORM 事务中: -``` -POST /api/payment/callback/agent-recharge/{payment_channel} -``` +1. 条件更新支付单为已支付并保存第三方交易号与支付时间; +2. 条件更新代理充值单从 `1=待支付` 为 `2=已支付`; +3. 写入稳定版本的代理充值入账 Outbox。 -其中 `payment_channel` 为 `wechat_direct` 或 `fuyou`。 +支付渠道成功响应 MUST 在上述事务提交后返回。钱包入账不得继续作为支付回调事务中的同步步骤。 -**鉴权** +Outbox 消费者 MUST 在独立事务中复用统一代理主钱包入账能力,锁定目标主钱包、增加余额、创建唯一成功流水、将充值单从 `2=已支付` 更新为 `3=已完成`,并写入现有钱包入账事件。消费者 MUST 以充值记录 ID 作为业务幂等引用;重复回调、重复 Outbox 或 Worker 重试不得重复增加余额。 -- 无需登录态 -- 通过签名验证确认请求来源合法性 +#### Scenario: 微信支付成功回调 +- **WHEN** 微信回调验签通过,支付单类型为 `agent_recharge`,且金额、配置、商户身份和业务关联全部一致 +- **THEN** 系统 MUST 固化支付成功事实和入账 Outbox,并向微信返回渠道成功响应 +- **THEN** 钱包余额由独立消费者完成,不得在回调事务中同步增加 ---- +#### Scenario: 支付宝支付成功回调 +- **WHEN** 支付宝通知验签通过,交易状态为成功,支付单类型为 `agent_recharge`,且金额、配置、应用身份和业务关联全部一致 +- **THEN** 系统 MUST 固化支付成功事实和入账 Outbox,并向支付宝返回 `success` -**处理流程** +#### Scenario: 回调金额或关联不一致 +- **WHEN** 回调金额与支付单或充值单不一致,或支付单未关联该代理充值记录 +- **THEN** 系统 MUST 拒绝处理并保留原状态,钱包余额和流水 MUST NOT 改变 -``` -1. 接收回调请求 -2. 根据 payment_channel 确定验签方式 -3. 通过 recharge_no(充值单号)查找充值记录 -4. 幂等性检查:若记录状态已为 2(已完成),直接返回成功 -5. 使用充值记录中的 payment_config_id 查找对应支付配置 -6. 使用支付配置的密钥验证签名 -7. 验签通过后,在事务中执行: - a. 更新充值记录状态为 2(已完成),记录 payment_transaction_id、paid_at、completed_at - b. 代理主钱包余额增加充值金额(乐观锁 version 字段防并发) - c. 创建钱包流水记录(类型:充值入账) -8. 返回支付平台要求的成功响应格式 -``` +#### Scenario: 重复支付回调 +- **WHEN** 同一第三方交易号对同一已支付或已完成充值单重复回调 +- **THEN** 系统 MUST 幂等返回渠道成功响应,不得重复写支付事实、入账 Outbox 或钱包流水 -**幂等性保障** +#### Scenario: 第三方交易号被其他支付单占用 +- **WHEN** 回调中的第三方交易号已绑定另一张支付单或代理充值单 +- **THEN** 系统 MUST 返回冲突并记录不含敏感信息的严重错误,任何钱包 MUST NOT 入账 -- 使用充值记录状态作为幂等判断依据(状态条件更新:`WHERE status = 1`) -- `RowsAffected == 0` 时说明已被处理,直接返回成功,不重复入账 +#### Scenario: 钱包入账暂时失败 +- **WHEN** 支付事实已提交但钱包入账消费者因锁冲突或暂时性基础设施错误失败 +- **THEN** 充值单 MUST 保持 `2=已支付`,Outbox/Worker MUST 重试,且支付成功事实不得回滚或要求代理再次付款 -**签名验证** - -- 根据充值记录的 `payment_config_id` 查找对应支付配置 -- 使用该配置的密钥(`api_key` / `app_secret`)按对应通道规则验签 -- 验签失败时记录错误日志,返回失败响应(不更新订单状态) - -**回调响应** - -- 微信直连:返回 `{"code": "SUCCESS", "message": "成功"}` -- 富友:按富友协议返回对应成功标识 -- 处理失败时返回对应通道的失败标识,触发第三方平台重试 - -**异常处理** - -- 充值记录不存在:记录警告日志,返回失败(触发重试,等待数据一致) -- 签名验证失败:记录错误日志(含完整请求体),返回失败 -- 钱包余额更新失败(乐观锁冲突):最多重试 3 次,仍失败则记录告警日志并返回失败 - ---- +#### Scenario: 重复执行钱包入账 +- **WHEN** 同一代理充值入账任务被重复消费 +- **THEN** 数据库唯一流水约束和状态条件更新 MUST 保证余额最多增加一次,并最终将充值单收敛为 `3=已完成` ### Requirement: 权限控制 -**账号类型与操作权限矩阵** +系统 MUST 使用以下权限边界: -| 操作 | 平台账号 | 代理账号 | 企业账号 | -|------|----------|----------|----------| -| 创建充值订单(在线) | ✅ 任意店铺 | ✅ 仅自己店铺 | ❌ | -| 创建充值订单(线下) | ✅ 任意店铺 | ❌ | ❌ | -| 线下充值确认 | ✅ | ❌ | ❌ | -| 查询充值列表 | ✅ 全部 | ✅ 仅自己店铺 | ❌ | -| 查询充值详情 | ✅ 全部 | ✅ 仅自己店铺 | ❌ | +| 操作 | 平台/超级管理员 | 代理账号 | 企业账号 | +|------|-----------------|----------|----------| +| 创建在线扫码充值 | 禁止 | 仅当前所属店铺主钱包 | 禁止 | +| 创建线下代充 | 按现有权限指定目标店铺 | 禁止 | 禁止 | +| 线下充值确认 | 允许 | 禁止 | 禁止 | +| 查询可用在线支付方式 | 禁止 | 允许 | 禁止 | +| 查询充值列表与详情 | 按既有数据范围 | 按既有店铺层级与查看权限 | 禁止 | +| 查询在线支付状态 | 按既有数据范围 | 按既有店铺层级与查看权限 | 禁止 | -**越权防护规则** +路由层 MUST 对企业账号执行粗粒度拦截;Application/Query MUST 执行创建角色、当前店铺、资源归属和查看权限校验;GORM 数据范围过滤保持启用。越权与资源不存在 MUST 统一返回 `CodeForbidden` 和“无权限操作该资源或资源不存在”,不得泄露资源是否存在。 -1. **路由层**:企业账号访问代理充值相关接口,统一返回 `1005 CodeForbidden` -2. **Service 层**: - - 代理账号创建充值时,验证 `shop_id` 必须属于自己所属店铺 - - 代理账号查询详情时,验证充值记录的 `shop_id` 必须属于自己所属店铺 -3. **越权统一响应**:不区分"不存在"和"无权限",统一返回 `1005` 或对应资源不存在错误,防止信息泄露 +线下充值确认继续使用既有平台操作密码契约;密码验证失败返回 `CodeInvalidOldPassword`,响应和日志均不得记录密码明文。 -**线下充值操作密码** +#### Scenario: 代理为当前店铺充值 +- **WHEN** 代理账号创建在线扫码充值且当前店铺主钱包可用 +- **THEN** 系统 MUST 以认证上下文中的店铺和主钱包作为唯一受益方 -- 平台账号执行线下充值确认时,必须提供操作密码 -- 操作密码验证失败返回 `1043 CodeInvalidOldPassword` -- 操作密码不在响应中返回,不记录到日志明文中 +#### Scenario: 平台尝试创建在线扫码充值 +- **WHEN** 平台或超级管理员提交 `wechat` 或 `alipay` 代理充值请求 +- **THEN** 系统 MUST 返回 `CodeForbidden`,并引导其使用既有线下代充审批路径 + +#### Scenario: 无权读取支付状态 +- **WHEN** 登录账号查询其数据范围外充值单的支付状态 +- **THEN** 系统 MUST 返回统一禁止访问错误,不得返回支付状态、付款内容或钱包余额 + +--- + +### Requirement: 查询代理在线充值可用支付方式 + +系统 SHALL 提供 `GET /api/admin/agent-recharges/payment-methods`,仅根据当前生效支付配置返回真正具备扫码预下单、回调验签和查单能力的在线支付方式。路由 MUST 注册在 `/:id` 动态路由之前。 + +成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式;`data.methods` MUST 为按 `wechat`、`alipay` 固定顺序排列的字符串数组,并同时返回 `min_amount=10000` 与 `max_amount=100000000`。接口不得返回支付配置 ID、商户号、应用 ID、密钥或具体缺失的敏感配置。 + +#### Scenario: 微信与支付宝均可用 +- **WHEN** 当前支付配置完整支持微信 Native 和支付宝 PreCreate +- **THEN** 接口 MUST 返回 `methods=["wechat","alipay"]` 及在线金额上下限 + +#### Scenario: 没有可用扫码支付方式 +- **WHEN** 当前配置不支持任何已约定的扫码支付方式 +- **THEN** 接口 MUST 成功返回空数组,不得伪造可用渠道 + +### Requirement: 查询代理在线充值支付状态 + +系统 SHALL 提供 `GET /api/admin/agent-recharges/:id/payment-status` 供桌面端轮询本地事实。接口 MUST 仅查询 PostgreSQL 本地支付单、充值单及必要的钱包入账结果,不得在每次轮询时调用第三方支付渠道。 + +响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并至少返回 `status`、`status_name`、`payment_status`、`payment_status_name`、`paid_at` 与 `completed_at`。`1=待支付` MUST 表示尚未确认收款,`2=已支付` MUST 表示第三方收款已确认但钱包仍在入账,`3=已完成` MUST 表示钱包余额和唯一流水已经提交。 + +接口 MUST NOT 返回 `qr_content`、支付密钥、签名参数、内部重试错误或其他店铺余额。第一期 MUST NOT 提供主动取消、支付方式切换、在线退款或本地推算的精确二维码倒计时。 + +#### Scenario: 等待扫码支付 +- **WHEN** 充值单仍为待支付且支付单未确认成功 +- **THEN** 接口 MUST 返回待支付状态,不得调用第三方查单 + +#### Scenario: 已支付等待钱包入账 +- **WHEN** 支付单已支付且充值单状态为 `2=已支付` +- **THEN** 接口 MUST 明确返回支付成功、入账处理中,不得显示支付失败 + +#### Scenario: 钱包已经到账 +- **WHEN** 充值单状态为 `3=已完成` 且唯一钱包流水存在 +- **THEN** 接口 MUST 返回已完成和完成时间 + +### Requirement: 待支付订单受控收敛 + +系统 MUST 通过后台受控任务查询长期待支付的代理在线充值支付单,以弥补第三方回调丢失。任务 MUST 使用创建支付单时记录的支付方式和 `payment_config_id` 调用对应查单 Adapter,并为每次外部尝试记录 Integration Log。 + +查单确认成功 MUST 复用与回调相同的支付确认用例;查单确认关闭或失效 MUST 条件关闭仍为待支付的支付单与充值单;未知、超时或渠道异常 MUST 保持原业务状态并按任务策略重试。迟到的真实成功通知经完整校验后 MUST 仍能固化支付事实并入账,不得因本地曾判断待支付或关闭而吞掉已收款资金。 + +#### Scenario: 回调丢失但查单成功 +- **WHEN** 待支付收敛任务从第三方查询到交易成功且金额与配置校验通过 +- **THEN** 系统 MUST 调用统一支付确认用例,写入支付事实和钱包入账 Outbox + +#### Scenario: 第三方明确订单关闭 +- **WHEN** 查单结果明确表示订单关闭或失效,且本地支付单仍为待支付 +- **THEN** 系统 MUST 条件更新支付单为失败并将充值单关闭,不得增加钱包余额 + +#### Scenario: 查单结果未知 +- **WHEN** 第三方超时、返回未知状态或暂时不可用 +- **THEN** 系统 MUST 保持本地待支付状态并重试,不得猜测支付失败 --- @@ -579,14 +556,16 @@ POST /api/payment/callback/agent-recharge/{payment_channel} | 值 | 含义 | |----|------| | 1 | 待支付(订单已创建,等待支付) | -| 2 | 已完成(支付成功,余额已到账) | -| 3 | 已取消(超时未支付或主动取消) | +| 2 | 已支付(第三方收款已确认,钱包入账处理中) | +| 3 | 已完成(钱包余额和唯一流水已提交) | +| 4 | 已关闭(第三方预下单失败或确认订单已关闭) | **支付方式枚举** | 值 | 含义 | |----|------| -| wechat | 微信在线支付(自动路由至微信直连或富友) | +| wechat | 微信 Native 扫码支付 | +| alipay | 支付宝当面付扫码支付 | | offline | 线下转账(仅平台账号可用) | **支付通道枚举** @@ -594,5 +573,5 @@ POST /api/payment/callback/agent-recharge/{payment_channel} | 值 | 含义 | |----|------| | wechat_direct | 微信直连通道 | -| fuyou | 富友通道 | +| alipay | 支付宝通道 | | offline | 线下转账 | diff --git a/pkg/constants/wecom.go b/pkg/constants/wecom.go index 887946c..3b0ec65 100644 --- a/pkg/constants/wecom.go +++ b/pkg/constants/wecom.go @@ -9,6 +9,8 @@ const ( IntegrationOperationWeComAccessToken = "get_access_token" // IntegrationOperationWeComVisibleMembers 表示获取应用可见成员。 IntegrationOperationWeComVisibleMembers = "list_visible_members" + // IntegrationOperationWeComVisibleDepartments 表示获取应用可见部门。 + IntegrationOperationWeComVisibleDepartments = "list_visible_departments" // IntegrationOperationWeComTemplateDetail 表示获取企微审批模板详情。 IntegrationOperationWeComTemplateDetail = "get_template_detail" // IntegrationOperationWeComAttachmentUpload 表示上传审批附件临时素材。 @@ -41,8 +43,6 @@ const ( WeComDefaultHTTPTimeout = 10 * time.Second // WeComDirectoryMaxResponseBodyBytes 表示通讯录响应正文允许读取的最大字节数。 WeComDirectoryMaxResponseBodyBytes int64 = 10 << 20 - // WeComRootDepartmentID 表示企业微信根部门 ID。 - WeComRootDepartmentID int64 = 1 // WeComMemberSyncBatchSize 表示可见成员快照批量写入大小。 WeComMemberSyncBatchSize = 500 // WeComApprovalMaxAttachmentCount 表示单张企微审批单最多允许的附件数量。