From 3bb7fa8db32d41feece5db3fb8d4aeb22118f5b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Tue, 29 Sep 2026 10:57:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + api/admin/v1/admin.go | 191 ++- api/admin/v1/coupon.go | 128 ++ api/admin/v1/event.go | 427 +++++++ api/admin/v1/order.go | 79 ++ api/admin/v1/promo.go | 93 ++ api/admin/v1/user_detail.go | 75 ++ api/user/v1/event.go | 96 ++ api/user/v1/member.go | 31 +- api/user/v1/note.go | 85 ++ api/user/v1/order.go | 94 ++ api/user/v1/promo.go | 107 ++ api/user/v1/quota.go | 30 +- api/user/v1/user.go | 12 + internal/cmd/cmd.go | 74 +- internal/cmd/cmd_test.go | 121 ++ internal/consts/consts.go | 253 +++- internal/controller/admin.go | 38 +- internal/controller/admin_order.go | 24 + internal/controller/coupon.go | 68 + internal/controller/event.go | 116 ++ internal/controller/event_pub.go | 31 + internal/controller/member.go | 17 +- internal/controller/note.go | 46 + internal/controller/promo.go | 55 + internal/controller/quota.go | 7 +- internal/controller/user.go | 5 + internal/logic/admin.go | 365 +++++- internal/logic/admin_account.go | 193 +++ internal/logic/admin_audit.go | 142 +++ internal/logic/admin_batch.go | 245 ++++ internal/logic/admin_batch_test.go | 58 + internal/logic/admin_level_s10.go | 26 + internal/logic/admin_level_s10_test.go | 38 + internal/logic/admin_level_test.go | 78 ++ internal/logic/admin_order.go | 385 ++++++ internal/logic/admin_order_test.go | 536 ++++++++ internal/logic/admin_perm.go | 207 ++++ internal/logic/admin_perm_test.go | 102 ++ internal/logic/admin_quota.go | 14 +- internal/logic/admin_test.go | 67 + internal/logic/coupon.go | 813 ++++++++++++ internal/logic/coupon_test.go | 106 ++ internal/logic/event_code.go | 109 ++ internal/logic/event_enterprise.go | 330 +++++ internal/logic/event_exclude_test.go | 112 ++ internal/logic/event_import.go | 556 +++++++++ internal/logic/event_import_test.go | 149 +++ internal/logic/event_layout.go | 1097 +++++++++++++++++ internal/logic/event_public.go | 43 + internal/logic/event_public_test.go | 72 ++ internal/logic/event_query.go | 433 +++++++ internal/logic/event_query_log.go | 111 ++ internal/logic/event_query_log_test.go | 53 + internal/logic/event_query_test.go | 218 ++++ internal/logic/event_seat_test.go | 229 ++++ internal/logic/jwt.go | 8 +- internal/logic/member.go | 206 ++-- internal/logic/migrate.go | 226 ++++ internal/logic/note.go | 471 +++++++ internal/logic/note_test.go | 107 ++ internal/logic/order_pay.go | 679 ++++++++++ internal/logic/order_pay_owner_test.go | 76 ++ internal/logic/order_pay_test.go | 396 ++++++ internal/logic/order_pay_tz_test.go | 78 ++ internal/logic/promo.go | 623 ++++++++++ internal/logic/promo_test.go | 158 +++ internal/logic/quota.go | 2 +- internal/logic/quota_api.go | 41 +- internal/logic/remind.go | 173 +++ internal/logic/seed.go | 22 +- internal/logic/softdelete.go | 187 +++ internal/logic/softdelete_it_test.go | 426 +++++++ internal/logic/softdelete_test.go | 106 ++ internal/logic/tools.go | 8 +- internal/logic/upload.go | 163 +++ internal/logic/upload_test.go | 91 ++ internal/logic/user.go | 12 +- internal/logic/user_detail.go | 223 ++++ internal/logic/wxmsg.go | 101 ++ internal/logic/xpay.go | 8 + internal/model/entity/entity_annual_events.go | 21 + internal/model/entity/entity_coupons.go | 29 + internal/model/entity/entity_employees.go | 22 + internal/model/entity/entity_enterprises.go | 14 + .../model/entity/entity_event_import_logs.go | 27 + .../model/entity/entity_event_participants.go | 16 + .../model/entity/entity_event_seat_queries.go | 17 + internal/model/entity/entity_event_seats.go | 18 + internal/model/entity/entity_event_tables.go | 23 + internal/model/entity/entity_notes.go | 31 + internal/model/entity/entity_promo_codes.go | 30 + internal/model/entity/entity_tools.go | 2 + internal/model/entity/entity_user_coupons.go | 36 + internal/model/entity/entity_users.go | 3 + sql/member_orders_refund.sql | 31 + 96 files changed, 13890 insertions(+), 213 deletions(-) create mode 100644 api/admin/v1/coupon.go create mode 100644 api/admin/v1/event.go create mode 100644 api/admin/v1/order.go create mode 100644 api/admin/v1/promo.go create mode 100644 api/admin/v1/user_detail.go create mode 100644 api/user/v1/event.go create mode 100644 api/user/v1/note.go create mode 100644 api/user/v1/order.go create mode 100644 api/user/v1/promo.go create mode 100644 internal/cmd/cmd_test.go create mode 100644 internal/controller/admin_order.go create mode 100644 internal/controller/coupon.go create mode 100644 internal/controller/event.go create mode 100644 internal/controller/event_pub.go create mode 100644 internal/controller/note.go create mode 100644 internal/controller/promo.go create mode 100644 internal/logic/admin_account.go create mode 100644 internal/logic/admin_audit.go create mode 100644 internal/logic/admin_batch.go create mode 100644 internal/logic/admin_batch_test.go create mode 100644 internal/logic/admin_level_s10.go create mode 100644 internal/logic/admin_level_s10_test.go create mode 100644 internal/logic/admin_level_test.go create mode 100644 internal/logic/admin_order.go create mode 100644 internal/logic/admin_order_test.go create mode 100644 internal/logic/admin_perm.go create mode 100644 internal/logic/admin_perm_test.go create mode 100644 internal/logic/admin_test.go create mode 100644 internal/logic/coupon.go create mode 100644 internal/logic/coupon_test.go create mode 100644 internal/logic/event_code.go create mode 100644 internal/logic/event_enterprise.go create mode 100644 internal/logic/event_exclude_test.go create mode 100644 internal/logic/event_import.go create mode 100644 internal/logic/event_import_test.go create mode 100644 internal/logic/event_layout.go create mode 100644 internal/logic/event_public.go create mode 100644 internal/logic/event_public_test.go create mode 100644 internal/logic/event_query.go create mode 100644 internal/logic/event_query_log.go create mode 100644 internal/logic/event_query_log_test.go create mode 100644 internal/logic/event_query_test.go create mode 100644 internal/logic/event_seat_test.go create mode 100644 internal/logic/note.go create mode 100644 internal/logic/note_test.go create mode 100644 internal/logic/order_pay.go create mode 100644 internal/logic/order_pay_owner_test.go create mode 100644 internal/logic/order_pay_test.go create mode 100644 internal/logic/order_pay_tz_test.go create mode 100644 internal/logic/promo.go create mode 100644 internal/logic/promo_test.go create mode 100644 internal/logic/remind.go create mode 100644 internal/logic/softdelete.go create mode 100644 internal/logic/softdelete_it_test.go create mode 100644 internal/logic/softdelete_test.go create mode 100644 internal/logic/upload.go create mode 100644 internal/logic/upload_test.go create mode 100644 internal/logic/user_detail.go create mode 100644 internal/logic/wxmsg.go create mode 100644 internal/model/entity/entity_annual_events.go create mode 100644 internal/model/entity/entity_coupons.go create mode 100644 internal/model/entity/entity_employees.go create mode 100644 internal/model/entity/entity_enterprises.go create mode 100644 internal/model/entity/entity_event_import_logs.go create mode 100644 internal/model/entity/entity_event_participants.go create mode 100644 internal/model/entity/entity_event_seat_queries.go create mode 100644 internal/model/entity/entity_event_seats.go create mode 100644 internal/model/entity/entity_event_tables.go create mode 100644 internal/model/entity/entity_notes.go create mode 100644 internal/model/entity/entity_promo_codes.go create mode 100644 internal/model/entity/entity_user_coupons.go create mode 100644 sql/member_orders_refund.sql diff --git a/.gitignore b/.gitignore index 8a29ecd..0855458 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ output/ manifest/output/ # 运行时日志(由 manifest/config/config.yaml 的 logger.path 指定) manifest/logs/ +# go test 在包目录下运行时,logger.path 相对 cwd 生成的日志目录(如 internal/logic/manifest/logs/) +**/manifest/logs/ temp/ temp.yaml bin diff --git a/api/admin/v1/admin.go b/api/admin/v1/admin.go index 8a6d06b..1a3e95c 100644 --- a/api/admin/v1/admin.go +++ b/api/admin/v1/admin.go @@ -21,13 +21,95 @@ type MyInfoReq struct { g.Meta `path:"/user/my-info" method:"get" tags:"admin" summary:"当前管理员信息"` } type MyInfoRes struct { + Id int64 `json:"id"` + Account string `json:"account"` + NickName string `json:"nick_name"` + RoleName string `json:"role_name"` + RoleValue string `json:"role_value"` + Permissions []string `json:"permissions"` // T17:当前角色拥有的权限点,前端据此隐藏入口 +} + +// ===== 管理员管理(T17)===== + +type AdminItem struct { Id int64 `json:"id"` Account string `json:"account"` NickName string `json:"nick_name"` RoleName string `json:"role_name"` RoleValue string `json:"role_value"` + Status int `json:"status"` + CreatedAt string `json:"created_at"` } +type AdminListReq struct { + g.Meta `path:"/admin/list" method:"get" tags:"admin" summary:"管理员列表"` +} +type AdminListRes struct { + List []AdminItem `json:"list"` +} + +type AdminSaveReq struct { + g.Meta `path:"/admin/save" method:"post" tags:"admin" summary:"新增/修改管理员"` + Id int64 `json:"id"` + Account string `json:"account"` // 新增必填;编辑时不可改 + Password string `json:"password"` + NickName string `json:"nick_name"` + RoleName string `json:"role_name"` + RoleValue string `json:"role_value" v:"required|in:super,operator,readonly#角色非法"` +} +type AdminSaveRes struct{} + +type AdminStatusReq struct { + g.Meta `path:"/admin/status" method:"post" tags:"admin" summary:"启停管理员"` + Id int64 `v:"required" json:"id"` + Status int `json:"status"` +} +type AdminStatusRes struct{} + +// ===== 操作审计(T17)===== + +type AuditItem struct { + Id int64 `json:"id"` + AdminId int64 `json:"admin_id"` + AdminAccount string `json:"admin_account"` + AdminName string `json:"admin_name"` + RoleValue string `json:"role_value"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetId string `json:"target_id"` + BeforeJson string `json:"before_json"` + AfterJson string `json:"after_json"` + Result int `json:"result"` + Remark string `json:"remark"` + Ip string `json:"ip"` + CreatedAt string `json:"created_at"` +} + +type AuditListReq struct { + g.Meta `path:"/audit/list" method:"get" tags:"admin" summary:"操作审计列表"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + AdminKeyword string `json:"adminKeyword"` // 操作人账号模糊匹配 + Action string `json:"action"` // 模糊匹配 + Result *int `json:"result"` // nil=全部 + DateFrom string `json:"dateFrom"` // YYYY-MM-DD + DateTo string `json:"dateTo"` // YYYY-MM-DD +} +type AuditListRes struct { + List []AuditItem `json:"list"` + Total int `json:"total"` +} + +// AuditRecordReq 前端主动上报审计(如「导出」为纯前端动作,服务端无法感知) +type AuditRecordReq struct { + g.Meta `path:"/audit/record" method:"post" tags:"admin" summary:"上报一条操作审计"` + Action string `v:"required" json:"action"` + TargetType string `json:"target_type"` + TargetId string `json:"target_id"` + Remark string `json:"remark"` +} +type AuditRecordRes struct{} + // ===== 用户管理 ===== type AdminUserItem struct { @@ -50,6 +132,17 @@ type UserListReq struct { Page int `json:"page"` PageSize int `json:"pageSize"` Keyword string `json:"keyword"` + // ===== T16 多条件筛选 ===== + LevelKey string `json:"levelKey"` // 按等级筛选(空=全部) + Status *int `json:"status"` // 按状态筛选(nil=全部,0/1 精确匹配) + DateFrom string `json:"dateFrom"` // 注册时间范围起(YYYY-MM-DD) + DateTo string `json:"dateTo"` // 注册时间范围止(YYYY-MM-DD) + // ===== T16 排序(sortBy 走白名单,非法值回落默认)===== + SortBy string `json:"sortBy"` // id/created_at/level_key/status + SortOrder string `json:"sortOrder"` // asc/desc + // ===== T04.6 会员到期筛选 ===== + // expiring7 | expiring30 | expired | forever | none | 空 + MemberExpire string `json:"memberExpire"` } type UserListRes struct { List []AdminUserItem `json:"list"` @@ -60,6 +153,10 @@ type UserSetLevelReq struct { g.Meta `path:"/user/set-level" method:"post" tags:"admin" summary:"设置用户等级"` UserId int64 `v:"required" json:"userId"` LevelKey string `v:"required" json:"levelKey"` + // ExpireAt 到期时间;**空 = 永久(写 NULL)**。 + // 续费/开通时不得把已存在的 NULL(永久)覆写成有限期(约定 S10)。 + ExpireAt string `json:"expireAt"` + Remark string `json:"remark"` } type UserSetLevelRes struct{} @@ -89,10 +186,13 @@ type LevelListRes struct { List []LevelItem `json:"list"` } +// LevelSaveReq 保存等级。 +// 注意:LevelKey 是跨端契约字段(小程序按它判定权益),**任何情况必填**; +// 编辑路径同样要回传原值(服务端会校验其与库中一致,不允许变更)。 type LevelSaveReq struct { g.Meta `path:"/level/save" method:"post" tags:"admin" summary:"保存等级"` Id int64 `json:"id"` - LevelKey string `v:"required-if:id,0#等级标识不能为空" json:"levelKey"` + LevelKey string `v:"required#等级标识不能为空" json:"levelKey"` Name string `v:"required#等级名称不能为空" json:"name"` Modules []string `v:"required#至少勾选一个模块" json:"modules"` Sort int `json:"sort"` @@ -159,6 +259,12 @@ type ToolsListReq struct { ModuleKey string `json:"moduleKey"` Keyword string `json:"keyword"` IncludeDisabled bool `json:"includeDisabled"` + // ===== T16 多条件筛选 ===== + IsEnabled *int `json:"isEnabled"` // 按上下架筛选(nil=不限) + IsHot *int `json:"isHot"` // 按推荐位筛选(nil=不限) + // ===== T16 排序(sortBy 走白名单,非法值回落默认)===== + SortBy string `json:"sortBy"` // id/sort/usage_count/is_enabled/is_hot/name/module_key + SortOrder string `json:"sortOrder"` // asc/desc } type ToolsListRes struct { List []AdminToolItem `json:"list"` @@ -203,15 +309,55 @@ type TopItem struct { Count int64 `json:"count"` } +// AmountItem 金额趋势点(T16):cents 为「分」,避免浮点误差。 +type AmountItem struct { + Date string `json:"date"` + Cents int64 `json:"cents"` +} + +// LevelDistItem 等级分布项(T16) +type LevelDistItem struct { + LevelKey string `json:"level_key"` + Name string `json:"name"` + Count int64 `json:"count"` +} + +// DistItem 通用分布项(T16):key 机读,label 展示 +type DistItem struct { + Key string `json:"key"` + Label string `json:"label"` + Value int64 `json:"value"` +} + type DashboardStatsReq struct { g.Meta `path:"/dashboard/stats" method:"get" tags:"admin" summary:"看板统计"` } + +// DashboardStatsRes 看板统计响应。 +// +// ⚠️ 历史豁免(S1 例外):本结构体出参为 **camelCase**(userCount / revenueCents / +// quotaFreeUsed …),系与前端 qitongxue-admin/src/views/dashboard/Dashboard.vue 一致 +// 约定的**既有接口**,**勿单独改成 snake_case** —— 改动必须前后端同步,否则会直接打断看板。 +// 注意本接口键风格本身**不对称**:顶层键为 camelCase,而嵌套项为 snake_case +// (如 levelDist 元素读 level_key / name / count,见 Dashboard.vue:108)。 +// 豁免**仅限已列明的既有接口,不得新增**:新增接口一律 snake_case(铁律 S1)。 type DashboardStatsRes struct { UserCount int64 `json:"userCount"` TodayActive int64 `json:"todayActive"` TotalUsage int64 `json:"totalUsage"` WeekTrend []TrendItem `json:"weekTrend"` ToolsTop []TopItem `json:"toolsTop"` + + // ===== T16 指标扩充(全部后端聚合,前端不再拉全量自算)===== + MemberCount int64 `json:"memberCount"` // 当前有效会员数(有到期时间且未过期、等级高于默认) + OrderCount int64 `json:"orderCount"` // 已发货订单数 + RevenueCents int64 `json:"revenueCents"` // 累计收入(已发货订单金额,分) + RevenueTrend []AmountItem `json:"revenueTrend"` // 近 7 日收入趋势(分,缺失补 0) + QuotaFreeUsed int64 `json:"quotaFreeUsed"` // 本周期免费额度已用合计 + QuotaMemberUsed int64 `json:"quotaMemberUsed"` // 本周期会员额度已用合计 + QuotaPaidUsed int64 `json:"quotaPaidUsed"` // 付费额度累计已消耗(次数包) + LevelDist []LevelDistItem `json:"levelDist"` // 各等级用户分布 + OrderDist []DistItem `json:"orderDist"` // 订单构成(会员套餐 / 次数包) } // ===== 反馈管理 ===== @@ -241,3 +387,46 @@ type FeedbackHandleReq struct { Id int64 `v:"required" json:"id"` } type FeedbackHandleRes struct{} + +// ===== 批量操作(T15)===== +// +// 统一契约:请求 ids + action(+参数),响应「部分成功」结果: +// +// success 成功的条数 +// failed 失败的条数 +// failures 失败明细(id + 原因),用于前端逐条展示 +// +// 语义:幂等(重复提交同一批得到同样结果);不存在的 id 计入 failed 而非整体报错。 + +// BatchFailure 单条失败明细 +type BatchFailure struct { + Id int64 `json:"id"` + Reason string `json:"reason"` +} + +// UserBatchReq 批量操作用户:action=status(启停)/ level(改等级)/ delete(软删除,可恢复) +type UserBatchReq struct { + g.Meta `path:"/user/batch" method:"post" tags:"admin" summary:"批量操作用户"` + Ids []int64 `v:"required|min-length:1#请选择要操作的用户" json:"ids"` + Action string `v:"required|in:status,level,delete#动作非法" json:"action"` + Status int `json:"status"` // action=status 时使用(0/1) + LevelKey string `json:"level_key"` // action=level 时使用 +} +type UserBatchRes struct { + Success int `json:"success"` + Failed int `json:"failed"` + Failures []BatchFailure `json:"failures"` +} + +// ToolsBatchReq 批量操作工具:action=is_enabled / is_hot / delete(软删除,可恢复) +type ToolsBatchReq struct { + g.Meta `path:"/tools/batch" method:"post" tags:"admin" summary:"批量操作工具"` + Ids []int64 `v:"required|min-length:1#请选择要操作的工具" json:"ids"` + Action string `v:"required|in:is_enabled,is_hot,delete#动作非法" json:"action"` + Value int `json:"value"` // 0/1 +} +type ToolsBatchRes struct { + Success int `json:"success"` + Failed int `json:"failed"` + Failures []BatchFailure `json:"failures"` +} diff --git a/api/admin/v1/coupon.go b/api/admin/v1/coupon.go new file mode 100644 index 0000000..b88a32a --- /dev/null +++ b/api/admin/v1/coupon.go @@ -0,0 +1,128 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端优惠券契约:券模板(A6–A8)+ 发放/记录/核销(A9–A11) +// ============================================================================ + +// CouponTemplateItem 券模板列表项 +type CouponTemplateItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + SubTitle string `json:"sub_title"` + CouponType int `json:"coupon_type"` + Kind int `json:"kind"` + Value int `json:"value"` + ThresholdCents int `json:"threshold_cents"` + Scope int `json:"scope"` + ScopeKeys []string `json:"scope_keys"` + TotalLimit int `json:"total_limit"` + PerUserLimit int `json:"per_user_limit"` + ValidFrom string `json:"valid_from"` + ValidTo string `json:"valid_to"` + Status int `json:"status"` + GrantedCount int64 `json:"granted_count"` // 已发 + UsedCount int64 `json:"used_count"` // 已用 +} + +// CouponTemplateListReq 券模板列表(A6) +type CouponTemplateListReq struct { + g.Meta `path:"/coupon/template/list" method:"get" tags:"coupon" summary:"券模板列表"` + Keyword string `json:"keyword"` + Status *int `json:"status"` + CouponType *int `json:"couponType"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type CouponTemplateListRes struct { + List []CouponTemplateItem `json:"list"` + Total int `json:"total"` +} + +// CouponTemplateSaveReq 新增/编辑券模板(A7)。 +// +// CouponType 1 免单券 / 2 展示券;**前端未显式传时后端按 2 兜底(fail-closed)**。 +type CouponTemplateSaveReq struct { + g.Meta `path:"/coupon/template/save" method:"post" tags:"coupon" summary:"保存券模板"` + Id int64 `json:"id"` + Title string `v:"required#券名不能为空" json:"title"` + SubTitle string `json:"subTitle"` + CouponType int `json:"couponType"` + Kind int `json:"kind"` + Value int `v:"min:0#面额不能为负" json:"value"` + ThresholdCents int `v:"min:0#门槛不能为负" json:"thresholdCents"` + Scope int `json:"scope"` + ScopeKeys []string `json:"scopeKeys"` + TotalLimit int `v:"min:0#总发行上限不能为负" json:"totalLimit"` + PerUserLimit int `v:"min:0#单人限领不能为负" json:"perUserLimit"` + ValidFrom string `json:"validFrom"` + ValidTo string `json:"validTo"` + Status int `json:"status"` + Remark string `json:"remark"` +} +type CouponTemplateSaveRes struct { + Id int64 `json:"id"` +} + +// CouponTemplateToggleReq 启停券模板(A8) +type CouponTemplateToggleReq struct { + g.Meta `path:"/coupon/template/toggle" method:"post" tags:"coupon" summary:"启停券模板"` + Id int64 `v:"required" json:"id"` + Status int `json:"status"` +} +type CouponTemplateToggleRes struct{} + +// CouponGrantReq 发放优惠券(A9):单用户 / 批量共用,靠 userIds 数量区分。 +type CouponGrantReq struct { + g.Meta `path:"/coupon/grant" method:"post" tags:"coupon" summary:"发放优惠券"` + CouponId int64 `v:"required#请选择券模板" json:"couponId"` + UserIds []int64 `v:"required|min-length:1#请选择要发放的用户" json:"userIds"` + Remark string `json:"remark"` +} +type CouponGrantRes struct { + Success int `json:"success"` + Failed int `json:"failed"` + Failures []BatchFailure `json:"failures"` +} + +// CouponRecordItem 发放记录项(券面快照 + 占用/核销轨迹) +type CouponRecordItem struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + CouponId int64 `json:"coupon_id"` + Title string `json:"title"` + CouponType int `json:"coupon_type"` + Kind int `json:"kind"` + Value int `json:"value"` + Status int `json:"status"` + Source int `json:"source"` + GrantedAt string `json:"granted_at"` + UsedAt string `json:"used_at"` + ExpireAt string `json:"expire_at"` + UsedBy string `json:"used_by"` + UsedOrderNo string `json:"used_order_no"` +} + +// CouponRecordListReq 发放记录列表(A10) +type CouponRecordListReq struct { + g.Meta `path:"/coupon/record/list" method:"get" tags:"coupon" summary:"优惠券发放记录"` + UserId *int64 `json:"userId"` + CouponId *int64 `json:"couponId"` + CouponType *int `json:"couponType"` + Status *int `json:"status"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type CouponRecordListRes struct { + List []CouponRecordItem `json:"list"` + Total int `json:"total"` +} + +// CouponRedeemReq 人工核销(A11):**仅展示券**;对免单券且 status != 0 返回 4011。 +type CouponRedeemReq struct { + g.Meta `path:"/coupon/redeem" method:"post" tags:"coupon" summary:"人工核销优惠券"` + UserCouponId int64 `v:"required" json:"userCouponId"` + Remark string `json:"remark"` +} +type CouponRedeemRes struct{} diff --git a/api/admin/v1/event.go b/api/admin/v1/event.go new file mode 100644 index 0000000..f01134c --- /dev/null +++ b/api/admin/v1/event.go @@ -0,0 +1,427 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端「年会座次」域(企业 / 员工 / 活动 / 桌 / 座) +// 依据:架构-2026-09-18 §5.2 A1–A17、§5.3 骨架。 +// 导入两段式(A6–A8)与小程序码(A18)、查座审计(A19)由 T09/T10 实现。 +// ============================================================================ + +// ===== 企业 ===== + +type EnterpriseItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + Contact string `json:"contact"` + Remark string `json:"remark"` + Status int `json:"status"` + EmpCount int64 `json:"emp_count"` // 员工数 +} + +type EnterpriseListReq struct { + g.Meta `path:"/event/enterprise/list" method:"get" tags:"event" summary:"企业列表"` + Keyword string `json:"keyword"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type EnterpriseListRes struct { + List []EnterpriseItem `json:"list"` + Total int `json:"total"` +} + +type EnterpriseSaveReq struct { + g.Meta `path:"/event/enterprise/save" method:"post" tags:"event" summary:"保存企业"` + Id int64 `json:"id"` + Name string `v:"required|length:1,128#企业名称不能为空" json:"name"` + Contact string `json:"contact"` + Remark string `json:"remark"` + Status int `json:"status"` +} +type EnterpriseSaveRes struct { + Id int64 `json:"id"` +} + +// ===== 员工 ===== + +type EmployeeItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + Phone string `json:"phone"` + Dept string `json:"dept"` + Remark string `json:"remark"` + Status int `json:"status"` + InEvent bool `json:"in_event"` // 是否已在当前活动候场名单 + ExcludeAssign int `json:"exclude_assign"` // 1=不参与排座(仅 in_event 时有意义) +} + +type EmployeeListReq struct { + g.Meta `path:"/event/employee/list" method:"get" tags:"event" summary:"员工库列表"` + EnterpriseId int64 `json:"enterprise_id"` + EventId int64 `json:"event_id"` + Keyword string `json:"keyword"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type EmployeeListRes struct { + List []EmployeeItem `json:"list"` + Total int `json:"total"` +} + +// EmployeeSaveReq 手工加人(临时人员)。EventId > 0 时同时纳入该活动候场名单。 +type EmployeeSaveReq struct { + g.Meta `path:"/event/employee/save" method:"post" tags:"event" summary:"新增/修改员工"` + Id int64 `json:"id"` + EnterpriseId int64 `v:"required#请选择企业" json:"enterprise_id"` + EventId int64 `json:"event_id"` + Name string `v:"required|length:1,64#姓名不能为空" json:"name"` + Phone string `v:"required|length:6,20#手机号格式不正确" json:"phone"` + Dept string `json:"dept"` + Remark string `json:"remark"` +} +type EmployeeSaveRes struct { + Id int64 `json:"id"` +} + +type EmployeeStatusReq struct { + g.Meta `path:"/event/employee/status" method:"post" tags:"event" summary:"员工启停"` + Id int64 `v:"required" json:"id"` + Status int `v:"in:0,1#状态取值非法" json:"status"` +} +type EmployeeStatusRes struct{} + +// EmployeeBatchReq 批量操作员工(T15):action=status(启停)/ delete(软删除,可恢复)。 +// 部分成功语义见 BatchFailure。 +type EmployeeBatchReq struct { + g.Meta `path:"/event/employee/batch" method:"post" tags:"event" summary:"批量操作员工"` + Ids []int64 `v:"required|min-length:1#请选择要操作的员工" json:"ids"` + Action string `v:"required|in:status,delete#动作非法" json:"action"` + Status int `v:"in:0,1#状态取值非法" json:"status"` +} +type EmployeeBatchRes struct { + Success int `json:"success"` + Failed int `json:"failed"` + Failures []BatchFailure `json:"failures"` +} + +// ParticipantExcludeReq 批量设置「不参与排座」(N4)。仅对已在候场名单(in_event)的员工生效。 +type ParticipantExcludeReq struct { + g.Meta `path:"/event/participant/exclude" method:"post" tags:"event" summary:"批量设置员工不参与排座"` + EventId int64 `v:"required" json:"event_id"` + EmployeeIds []int64 `v:"required|min-length:1#请选择员工" json:"employee_ids"` + ExcludeAssign int `v:"in:0,1#取值非法" json:"exclude_assign"` +} +type ParticipantExcludeRes struct { + Success int `json:"success"` // 实际更新的候场记录数 +} + +// ===== 活动 ===== + +type EventItem struct { + Id int64 `json:"id"` + EnterpriseId int64 `json:"enterprise_id"` + EnterpriseName string `json:"enterprise_name"` + Title string `json:"title"` + Venue string `json:"venue"` + EventTime string `json:"event_time"` + Status string `json:"status"` + LayoutVersion int `json:"layout_version"` + CodeUrl string `json:"code_url"` + TableCount int64 `json:"table_count"` +} + +type EventListReq struct { + g.Meta `path:"/event/admin/list" method:"get" tags:"event" summary:"活动列表"` + EnterpriseId int64 `json:"enterprise_id"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type EventListRes struct { + List []EventItem `json:"list"` + Total int `json:"total"` +} + +type EventSaveReq struct { + g.Meta `path:"/event/admin/save" method:"post" tags:"event" summary:"保存活动"` + Id int64 `json:"id"` + EnterpriseId int64 `v:"required#请选择企业" json:"enterprise_id"` + Title string `v:"required|length:1,128#活动主题不能为空" json:"title"` + Venue string `json:"venue"` + EventTime string `json:"event_time"` +} +type EventSaveRes struct { + Id int64 `json:"id"` +} + +type EventStatusReq struct { + g.Meta `path:"/event/admin/status" method:"post" tags:"event" summary:"发布/结束活动"` + EventId int64 `v:"required" json:"event_id"` + Status string `v:"required|in:draft,published,ended#状态取值非法" json:"status"` +} +type EventStatusRes struct{} + +// ===== 设计器全量(活动详情)===== + +type SeatOut struct { + Id int64 `json:"id"` + SeatNo int `json:"seat_no"` + IsManual bool `json:"is_manual"` + EmployeeId int64 `json:"employee_id"` + EmployeeName string `json:"employee_name"` +} + +type TableOut struct { + Id int64 `json:"id"` + TableNo string `json:"table_no"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Capacity int `json:"capacity"` + Rotation int `json:"rotation"` + TableType int `json:"table_type"` // 桌型:0普通 1主桌 2签到台 3媒体席 4备用桌 9其他 + Remark string `json:"remark"` // 桌备注 + ExcludeAuto int `json:"exclude_auto"` // 1=不参与自动排序 + Seats []SeatOut `json:"seats"` +} + +// UnassignedItem 未分配池成员(在候场名单但未绑定座位) +type UnassignedItem struct { + EmployeeId int64 `json:"employee_id"` + Name string `json:"name"` + Phone string `json:"phone"` + Dept string `json:"dept"` +} + +type EventDetailReq struct { + g.Meta `path:"/event/admin/detail" method:"get" tags:"event" summary:"活动设计器全量"` + EventId int64 `v:"required" json:"event_id"` +} +type EventDetailRes struct { + Id int64 `json:"id"` + EnterpriseId int64 `json:"enterprise_id"` + Title string `json:"title"` + Venue string `json:"venue"` + EventTime string `json:"event_time"` + Status string `json:"status"` + LayoutVersion int `json:"layout_version"` + HallLayout string `json:"hall_layout"` + Tables []TableOut `json:"tables"` + ParticipantCount int `json:"participant_count"` + SeatCount int `json:"seat_count"` + Unassigned []UnassignedItem `json:"unassigned"` +} + +// ===== 布局保存(CAS)===== + +// TableIn 布局内的一张桌(capacity 变更会联动座位) +type TableIn struct { + Id int64 `json:"id"` + TableNo string `json:"table_no"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Capacity int `json:"capacity"` + Rotation int `json:"rotation"` + TableType int `json:"table_type"` + Remark string `json:"remark"` + ExcludeAuto int `json:"exclude_auto"` +} + +// EventLayoutSaveReq 带 layout_version 提交 → 服务端 CAS 更新,冲突返回 4009。 +// 说明:LayoutVersion 允许为 0(首次保存版本即 0),故不用 required 校验,逻辑内校验 < 0。 +type EventLayoutSaveReq struct { + g.Meta `path:"/event/admin/layout/save" method:"post" tags:"event" summary:"保存大厅布局"` + EventId int64 `v:"required" json:"event_id"` + LayoutVersion int `v:"min:0#缺少布局版本号" json:"layout_version"` + HallLayout string `json:"hall_layout"` + Tables []TableIn `json:"tables"` +} +type EventLayoutSaveRes struct { + LayoutVersion int `json:"layout_version"` +} + +// ===== 桌 ===== + +// TableSaveReq 建/改桌(改人数联动补/删座)。 +// 特殊桌(签到台/媒体席/备用桌)允许 0 座,故 capacity 下限为 0。 +type TableSaveReq struct { + g.Meta `path:"/event/admin/table/save" method:"post" tags:"event" summary:"保存桌(含人数联动座位)"` + EventId int64 `v:"required" json:"event_id"` + Id int64 `json:"id"` + TableNo string `json:"table_no"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Capacity int `v:"min:0|max:50#每桌人数需在 0~50 之间" json:"capacity"` // 特殊桌允许 0 座 + Rotation int `json:"rotation"` + TableType int `v:"min:0|max:9#桌型取值非法" json:"table_type"` + Remark string `v:"length:0,50#备注最多 50 字" json:"remark"` + ExcludeAuto int `v:"in:0,1#取值非法" json:"exclude_auto"` +} +type TableSaveRes struct { + TableId int64 `json:"table_id"` + AddedSeats int `json:"added_seats"` // 补出的座位数 + RemovedSeats int `json:"removed_seats"` // 移除的座位数 + MovedToUnassigned int `json:"moved_to_unassigned"` // 因减座而移入未分配池的人数 +} + +type TableDeleteReq struct { + g.Meta `path:"/event/admin/table/delete" method:"post" tags:"event" summary:"删除桌"` + EventId int64 `v:"required" json:"event_id"` + TableId int64 `v:"required" json:"table_id"` +} +type TableDeleteRes struct { + MovedToUnassigned int `json:"moved_to_unassigned"` // 该桌原有员工数(删桌后全部进入未分配池) +} + +// ===== 座位 ===== + +// SeatSaveReq 改号 / 绑定 / 解绑。 +// +// SeatNo > 0 时改号(置 is_manual=1); +// EmployeeId 非 nil 时改绑定:指向 0 表示解绑,>0 表示绑定该员工。 +type SeatSaveReq struct { + g.Meta `path:"/event/admin/seat/save" method:"post" tags:"event" summary:"改座位号/绑定/解绑"` + EventId int64 `v:"required" json:"event_id"` + SeatId int64 `v:"required" json:"seat_id"` + SeatNo int `json:"seat_no"` + EmployeeId *int64 `json:"employee_id"` +} +type SeatSaveRes struct{} + +// SeatAutoReq mode: fill=增量补位(保留手工);reorder=全部重排(覆盖手工,需二次确认) +type SeatAutoReq struct { + g.Meta `path:"/event/admin/seat/auto" method:"post" tags:"event" summary:"自动排座"` + EventId int64 `v:"required" json:"event_id"` + Mode string `v:"required|in:fill,reorder#模式取值非法" json:"mode"` +} +type SeatAutoRes struct { + Assigned int `json:"assigned"` // 本次新分配人数 + Unassigned int `json:"unassigned"` // 仍未分配人数 + ManualOverridden int `json:"manual_overridden"` // 被覆盖/清空的手工座位数(reorder 才有意义) + Excluded int `json:"excluded"` // 本次「不参与排座」的员工人数 +} + +// ===== 员工导入(两段式:预览不落库 → 冲突裁决 → 确认入库)===== + +// ImportRow 一行解析后的员工数据(由管理端 SheetJS 解析后提交;后端不解析文件) +type ImportRow struct { + Name string `json:"name"` + Phone string `json:"phone"` + Dept string `json:"dept"` + Remark string `json:"remark"` +} + +type ImportPreviewRowOut struct { + Idx int `json:"idx"` + Type string `json:"type"` // new / update / conflict / error + Name string `json:"name"` + Phone string `json:"phone"` // 原样回显,前端展示可自行脱敏 + Dept string `json:"dept"` + Message string `json:"message"` // 分类原因(如"手机号缺失""同名不同号") +} + +// ImportPreviewReq 预览入参:已解析好的结构化行数组(非 multipart 文件) +type ImportPreviewReq struct { + g.Meta `path:"/event/employee/import-preview" method:"post" tags:"event" summary:"导入预览(不落库)"` + EnterpriseId int64 `v:"required#请选择企业" json:"enterprise_id"` + EventId int64 `json:"event_id"` + FileName string `json:"file_name"` + Rows []ImportRow `v:"required|min-length:1#文件没有有效数据" json:"rows"` +} + +type ImportPreviewRes struct { + ImportLogId int64 `json:"import_log_id"` // 确认时回传;服务端据此从库读回快照 + Total int `json:"total"` + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Conflict int `json:"conflict"` + Rows []ImportPreviewRowOut `json:"rows"` +} + +// ImportDecision action: merge(视为同一人·更新手机号) / create(视为另一个人新增) / ignore +type ImportDecision struct { + Idx int `json:"idx"` + Action string `v:"required|in:merge,create,ignore#裁决动作非法" json:"action"` +} + +type ImportConfirmReq struct { + g.Meta `path:"/event/employee/import-confirm" method:"post" tags:"event" summary:"确认导入"` + ImportLogId int64 `v:"required#导入标识缺失" json:"import_log_id"` + Decisions []ImportDecision `json:"decisions"` +} +type ImportConfirmRes struct { + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` +} + +type ImportLogItem struct { + Id int64 `json:"id"` + EnterpriseId int64 `json:"enterprise_id"` + EventId int64 `json:"event_id"` + FileName string `json:"file_name"` + Status string `json:"status"` + Total int `json:"total"` + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Conflict int `json:"conflict"` + Operator string `json:"operator"` + CreatedAt string `json:"created_at"` +} + +type ImportLogListReq struct { + g.Meta `path:"/event/import/log/list" method:"get" tags:"event" summary:"导入历史"` + EnterpriseId int64 `json:"enterprise_id"` + EventId int64 `json:"event_id"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type ImportLogListRes struct { + List []ImportLogItem `json:"list"` + Total int `json:"total"` +} + +// ===== 活动小程序码(A18)===== + +// EventQrcodeReq 生成活动小程序码(scene 只承载 event_id) +type EventQrcodeReq struct { + g.Meta `path:"/event/admin/qrcode" method:"post" tags:"event" summary:"生成活动小程序码"` + EventId int64 `v:"required" json:"event_id"` +} +type EventQrcodeRes struct { + CodeUrl string `json:"code_url"` +} + +// ===== 查座审计(A19,架构 §5.2 遗漏项补录)===== + +// QueryLogItem 查座审计列表项。 +// +// PhoneMasked 落库时已脱敏(138****0000),原样返回,不再二次脱敏; +// OpenidMasked 由服务端输出时脱敏(前 6 位 + ***),openid 为空时为空串; +// Result 0 失败 / 1 成功。 +type QueryLogItem struct { + Id int64 `json:"id"` + EventId int64 `json:"event_id"` + EventTitle string `json:"event_title"` + Ip string `json:"ip"` + PhoneMasked string `json:"phone_masked"` + OpenidMasked string `json:"openid_masked"` + Result int `json:"result"` + CreatedAt string `json:"created_at"` +} + +// EventQueryLogReq 查座审计查询条件:EventId=0 表示全部活动;Result=-1 表示全部结果。 +type EventQueryLogReq struct { + g.Meta `path:"/event/query/log/list" method:"get" tags:"event" summary:"查座审计列表"` + EventId int64 `json:"event_id"` // 0 = 全部 + Result int `json:"result"` // -1 全部 / 0 失败 / 1 成功 + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type EventQueryLogRes struct { + List []QueryLogItem `json:"list"` + Total int `json:"total"` +} diff --git a/api/admin/v1/order.go b/api/admin/v1/order.go new file mode 100644 index 0000000..e1a5b14 --- /dev/null +++ b/api/admin/v1/order.go @@ -0,0 +1,79 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端「订单管理 + 退款」契约 +// +// 铁律 S1:请求体 camelCase / 响应 snake_case。 +// 订单来源:member_orders(同一张表承载两类商品,order_type 1=会员套餐 2=次数包)。 +// +// 退款是可逆性资金动作:POST /order/refund 允许在退款的同时**可选**回收所购服务 +// (revokeService=true 回收权益;false 仅改状态、权益保留)。 +// ============================================================================ + +// OrderItem 订单列表项。 +type OrderItem struct { + Id int64 `json:"id"` + OutTradeNo string `json:"out_trade_no"` + // ===== 归属用户信息 ===== + UserId int64 `json:"user_id"` + Nickname string `json:"nickname"` + Username string `json:"username"` + AvatarUrl string `json:"avatar_url"` + // ===== 商品 ===== + OrderType int `json:"order_type"` // 1 会员套餐 / 2 次数包 + Title string `json:"title"` // 商品名(会员=套餐名;次数包=工具名·档位名) + PlanKey string `json:"plan_key"` + PackKey string `json:"pack_key"` + ToolKey string `json:"tool_key"` + Times int `json:"times"` // 次数包次数 + // ===== 金额(分)===== + OriginPriceCents int64 `json:"origin_price_cents"` + DiscountCents int64 `json:"discount_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + PromoKind int `json:"promo_kind"` // 0无 1优惠码 2免单券 + // ===== 状态 ===== + Status int `json:"status"` // 0待支付 1已发货 2已退款 3已关闭 + StatusText string `json:"status_text"` + ServiceRevoked int `json:"service_revoked"` // 退款时是否已回收所购服务:0未回收 1已回收 + // ===== 时间 ===== + CreatedAt string `json:"created_at"` // 创建时间 + PaidAt string `json:"paid_at"` // 支付时间(发货时写入) + RefundedAt string `json:"refunded_at"` // 退款时间 +} + +// OrderListReq 订单列表(分页 + 关键字 + 状态筛选)。 +type OrderListReq struct { + g.Meta `path:"/order/list" method:"get" tags:"admin" summary:"订单列表"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + // Keyword 关键字:匹配 订单号 / 用户昵称 / 用户名(模糊) + Keyword string `json:"keyword"` + // Status 状态筛选:nil=全部(0待支付 1已发货 2已退款 3已关闭) + Status *int `json:"status"` +} +type OrderListRes struct { + List []OrderItem `json:"list"` + Total int `json:"total"` +} + +// OrderRefundReq 订单退款。 +// +// RevokeService=true → 退款同时回收所购服务(会员套餐回收有效期;次数包扣回付费次数); +// false → 仅改状态为「已退款」,用户权益保留。 +// 仅「已发货(1)」订单可退款;已是「已退款(2)」的订单幂等返回成功但不重复回收权益。 +type OrderRefundReq struct { + g.Meta `path:"/order/refund" method:"post" tags:"admin" summary:"订单退款(可选回收所购服务)"` + OutTradeNo string `v:"required#订单号不能为空" json:"outTradeNo"` + RevokeService bool `json:"revokeService"` +} +type OrderRefundRes struct { + OutTradeNo string `json:"out_trade_no"` + Status int `json:"status"` + StatusText string `json:"status_text"` + ServiceRevoked bool `json:"service_revoked"` // 该订单当前是否已回收所购服务 + Revoked bool `json:"revoked"` // 本次调用是否实际执行了退款动作 + AlreadyRefunded bool `json:"already_refunded"` // 幂等命中:调用前已是已退款 + Message string `json:"message"` +} diff --git a/api/admin/v1/promo.go b/api/admin/v1/promo.go new file mode 100644 index 0000000..ea92a4c --- /dev/null +++ b/api/admin/v1/promo.go @@ -0,0 +1,93 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 管理端优惠码契约(A1–A5):list / save / toggle / delete / usages +// ============================================================================ + +// PromoItem 优惠码列表项(响应 snake_case) +type PromoItem struct { + Id int64 `json:"id"` + Code string `json:"code"` + ProductId string `json:"product_id"` + PriceCents int64 `json:"price_cents"` + Scope int `json:"scope"` + ScopeKeys []string `json:"scope_keys"` + MaxUses int `json:"max_uses"` + UsedCount int `json:"used_count"` + PerUserLimit int `json:"per_user_limit"` + ValidFrom string `json:"valid_from"` + ValidTo string `json:"valid_to"` + Status int `json:"status"` + Remark string `json:"remark"` +} + +// PromoListReq 优惠码列表(A1) +type PromoListReq struct { + g.Meta `path:"/promo/list" method:"get" tags:"promo" summary:"优惠码列表"` + Keyword string `json:"keyword"` + Status *int `json:"status"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type PromoListRes struct { + List []PromoItem `json:"list"` + Total int `json:"total"` +} + +// PromoSaveReq 新增/编辑优惠码(A2)。编辑(id>0)时 code 不可改(跨端契约)。 +type PromoSaveReq struct { + g.Meta `path:"/promo/save" method:"post" tags:"promo" summary:"保存优惠码"` + Id int64 `json:"id"` + Code string `v:"required#优惠码不能为空" json:"code"` + ProductId string `v:"required#支付道具不能为空" json:"productId"` + PriceCents int64 `v:"min:0#价格不能为负" json:"priceCents"` + Scope int `json:"scope"` + ScopeKeys []string `json:"scopeKeys"` + MaxUses int `v:"min:0#总上限不能为负" json:"maxUses"` + PerUserLimit int `v:"min:0#单人限用不能为负" json:"perUserLimit"` + ValidFrom string `json:"validFrom"` + ValidTo string `json:"validTo"` + Status int `json:"status"` + Remark string `json:"remark"` +} +type PromoSaveRes struct { + Id int64 `json:"id"` +} + +// PromoToggleReq 启停优惠码(A3) +type PromoToggleReq struct { + g.Meta `path:"/promo/toggle" method:"post" tags:"promo" summary:"启停优惠码"` + Id int64 `v:"required" json:"id"` + Status int `json:"status"` +} +type PromoToggleRes struct{} + +// PromoDeleteReq 删除优惠码(A4) +type PromoDeleteReq struct { + g.Meta `path:"/promo/delete" method:"post" tags:"promo" summary:"删除优惠码"` + Id int64 `v:"required" json:"id"` +} +type PromoDeleteRes struct{} + +// PromoUsageItem 优惠码使用明细项(A5,响应 snake_case) +type PromoUsageItem struct { + At string `json:"at"` + User string `json:"user"` + OutTradeNo string `json:"out_trade_no"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` +} + +// PromoUsagesReq 优惠码使用明细(A5) +type PromoUsagesReq struct { + g.Meta `path:"/promo/usages" method:"get" tags:"promo" summary:"优惠码使用明细"` + PromoId int64 `json:"promoId"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type PromoUsagesRes struct { + List []PromoUsageItem `json:"list"` + Total int `json:"total"` +} diff --git a/api/admin/v1/user_detail.go b/api/admin/v1/user_detail.go new file mode 100644 index 0000000..06fde83 --- /dev/null +++ b/api/admin/v1/user_detail.go @@ -0,0 +1,75 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" + + userv1 "tool-api/api/user/v1" +) + +// ============================================================================ +// 管理端「用户详情抽屉」聚合契约(A-new1):GET /user/detail +// +// 一次拉全:基础信息 / 额度 / 订单 / 笔记(仅标题·时间·提醒态) / 等级变更 / 优惠券。 +// 复用用户端已定义的 QuotaToolOut 与 UserCouponItem(跨包 import,无循环依赖)。 +// ============================================================================ + +// UserDetailBase 用户基础信息 +type UserDetailBase struct { + Id int64 `json:"id"` + Nickname string `json:"nickname"` + AvatarUrl string `json:"avatar_url"` + Openid string `json:"openid"` + LevelKey string `json:"level_key"` + LevelName string `json:"level_name"` + LevelExpireAt string `json:"level_expire_at"` // 空 = 永久 + Status int `json:"status"` + UsageCount int64 `json:"usage_count"` + CreatedAt string `json:"created_at"` +} + +// OrderBrief 订单摘要(含优惠凭证字段) +type OrderBrief struct { + OutTradeNo string `json:"out_trade_no"` + Title string `json:"title"` + OrderType int `json:"order_type"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + DiscountCents int64 `json:"discount_cents"` + PromoKind int `json:"promo_kind"` + Status int `json:"status"` + StatusText string `json:"status_text"` + CreatedAt string `json:"created_at"` + DeliveredAt string `json:"delivered_at"` +} + +// NoteBrief 笔记摘要(**不含正文**) +type NoteBrief struct { + Id int64 `json:"id"` + Title string `json:"title"` + RemindStatus int `json:"remind_status"` + RemindAt string `json:"remind_at"` + CreatedAt string `json:"created_at"` +} + +// LevelLogItem 等级变更记录(取自操作审计) +type LevelLogItem struct { + At string `json:"at"` + BeforeLevel string `json:"before_level"` + AfterLevel string `json:"after_level"` + Operator string `json:"operator"` + Remark string `json:"remark"` +} + +// UserDetailReq 用户详情聚合 +type UserDetailReq struct { + g.Meta `path:"/user/detail" method:"get" tags:"admin" summary:"用户详情(额度/订单/笔记/等级变更/券)"` + UserId int64 `v:"required" json:"userId"` +} +type UserDetailRes struct { + User UserDetailBase `json:"user"` + Quotas []userv1.QuotaToolOut `json:"quotas"` + Orders []OrderBrief `json:"orders"` + Notes []NoteBrief `json:"notes"` + LevelLogs []LevelLogItem `json:"level_logs"` + Coupons []userv1.UserCouponItem `json:"coupons"` +} diff --git a/api/user/v1/event.go b/api/user/v1/event.go new file mode 100644 index 0000000..b985fa4 --- /dev/null +++ b/api/user/v1/event.go @@ -0,0 +1,96 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 小程序端「年会」公开接口(免登录:/event/info、/event/seat/query) +// 依据:架构 §5.1 E1/E2、§5.3 骨架、§4.4 图④;PRD-02 §4.2.3 隐私边界。 +// +// 结果最小化红线:返回体**只含本人**的桌号/桌名/座位号,以及「本人所在桌」的示意 +// (各座位是否有人,但**不含任何他人姓名/手机号/全厅名单/统计**)。SeatCell 刻意只有 +// seat_no / is_self / filled 三个字段。 +// ============================================================================ + +// SeatCell 仅用于「本人所在桌」示意 —— 只含座位号与是否本人,不含任何他人信息 +type SeatCell struct { + SeatNo int `json:"seat_no"` + IsSelf bool `json:"is_self"` + Filled bool `json:"filled"` // 是否有人(不返回是谁) +} + +type StageOut struct { + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + Shape string `json:"shape"` + Rotation int `json:"rotation"` + Color string `json:"color"` +} + +type EventInfoReq struct { + g.Meta `path:"/event/info" method:"get" tags:"event" summary:"活动公开信息(查座页顶部)"` + EventId int64 `v:"required#活动不存在" json:"event_id"` +} +type EventInfoRes struct { + Title string `json:"title"` + Venue string `json:"venue"` + EventTime string `json:"event_time"` + Status string `json:"status"` + CanQuery bool `json:"can_query"` // status==published + Stage *StageOut `json:"stage"` // 用于定位,不含个人信息 +} + +// EventSeatQueryReq 姓名 + 手机号双因子精确匹配(都填且都对才返回) +type EventSeatQueryReq struct { + g.Meta `path:"/event/seat/query" method:"post" tags:"event" summary:"扫码查座(免登录)"` + EventId int64 `v:"required#活动不存在" json:"event_id"` + Name string `json:"name"` + Phone string `json:"phone"` +} + +// EventSeatQueryRes 结果最小化:只有本人桌座 + 本人所在桌示意。 +// +// Found=false 表示未查询到(双因子不匹配/未录入/未分配座位),message 为统一模糊文案, +// 前端据 Found 判定;找不到时所有桌座字段为空,不透出任何他人信息。 +type EventSeatQueryRes struct { + Found bool `json:"found"` + Message string `json:"message"` + TableNo string `json:"table_no"` + TableName string `json:"table_name"` + SeatNo int `json:"seat_no"` + Capacity int `json:"capacity"` + Seats []SeatCell `json:"seats"` // 本人所在桌,按座位号;Filled 不含身份 + Stage *StageOut `json:"stage"` + // 本人桌圆心 x(大厅逻辑坐标系)。**仅 Found=true 时有意义,未命中为 0**。 + TableX int `json:"table_x"` + // 本人桌圆心 y(大厅逻辑坐标系)。**仅 Found=true 时有意义,未命中为 0**。 + TableY int `json:"table_y"` + // 大厅逻辑宽(取自 hall_layout.canvas.w,缺省 1600)。**仅 Found=true 时有意义,未命中为 0**。 + HallW int `json:"hall_w"` + // 大厅逻辑高(取自 hall_layout.canvas.h,缺省 1200)。**仅 Found=true 时有意义,未命中为 0**。 + HallH int `json:"hall_h"` +} + +// ============================================================================ +// 公开「可查询活动列表」(N4-A / E-list,免登录) +// +// 只返回已发布(can_query=true)的活动,仅暴露最小字段(event_id / title / venue / event_time); +// 无已发布活动 → {list:[]}(**非报错**,前端据此渲染空态);草稿 / 已结束**不出现**。 +// ============================================================================ + +// EventPublicItem 公开活动条目(最小字段,不含任何组织/名单信息) +type EventPublicItem struct { + EventId int64 `json:"event_id"` + Title string `json:"title"` + Venue string `json:"venue"` + EventTime string `json:"event_time"` +} + +// EventPublicListReq 公开可查询活动列表 +type EventPublicListReq struct { + g.Meta `path:"/event/list" method:"get" tags:"event" summary:"公开可查询活动列表"` +} +type EventPublicListRes struct { + List []EventPublicItem `json:"list"` +} diff --git a/api/user/v1/member.go b/api/user/v1/member.go index af599d3..c8f6db4 100644 --- a/api/user/v1/member.go +++ b/api/user/v1/member.go @@ -52,22 +52,26 @@ type MemberCenterRes struct { PeriodDays int `json:"period_days"` // 当前生效的额度重置周期 } -// MemberOrderCreateReq 创建会员订单 +// MemberOrderCreateReq 创建会员订单(**只建单**,N1-1/2)。 // -// Code 为 wx.login 获取的临时登录凭证 —— 服务端用它换 session_key, -// session_key 是「用户态签名 signature」的密钥;同时校验支付人与登录人一致。 +// Code 保留以兼容旧客户端但**非必传**:建单不再校验「支付人 = 登录人」(该校验前移到 +// POST /member/order/pay)。两步式的第一步只负责落一条待支付订单。 type MemberOrderCreateReq struct { - g.Meta `path:"/member/order" method:"post" tags:"member" summary:"创建会员订单(返回支付参数)"` + g.Meta `path:"/member/order" method:"post" tags:"member" summary:"创建会员订单(待支付,不返回支付参数)"` PlanKey string `v:"required#请选择会员套餐" json:"plan_key"` Code string `json:"code"` } + +// MemberOrderCreateRes 创建会员订单响应(只建单,**不返回支付参数**;N1-1/2)。 +// +// 支付参数由独立端点 POST /member/order/pay 构建(见 order.go 的 MemberOrderPayRes)。 +// Reused=true 表示命中「同用户 + 同套餐 + 待支付」的既有单(N1-10),保证待支付单恒为 1 条。 type MemberOrderCreateRes struct { - OutTradeNo string `json:"out_trade_no"` - Mode string `json:"mode"` // 固定 short_series_goods - SignData string `json:"signData"` // JSON 字符串,必须原样传给 wx.requestVirtualPayment - PaySig string `json:"paySig"` - Signature string `json:"signature"` - Env int `json:"env"` + OutTradeNo string `json:"out_trade_no"` + Reused bool `json:"reused"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + Status int `json:"status"` } // MemberOrderCheckReq 查询订单状态(前端支付成功回调后轮询;同时承担兜底发货) @@ -91,6 +95,7 @@ type MemberOrderOut struct { OrderType int `json:"order_type"` // 1=会员套餐 2=次数包 PlanKey string `json:"plan_key"` PlanName string `json:"plan_name"` + PackKey string `json:"pack_key"` Title string `json:"title"` // 统一展示标题(套餐名 / 次数包名) ToolKey string `json:"tool_key"` ToolName string `json:"tool_name"` @@ -101,6 +106,12 @@ type MemberOrderOut struct { PayChannel string `json:"pay_channel"` CreatedAt string `json:"created_at"` DeliveredAt string `json:"delivered_at"` + // ===== T04.6 优惠凭证(响应 snake_case)===== + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + PromoCode string `json:"promo_code"` + DiscountCents int64 `json:"discount_cents"` + PromoKind int `json:"promo_kind"` } type MemberOrdersRes struct { List []MemberOrderOut `json:"list"` diff --git a/api/user/v1/note.go b/api/user/v1/note.go new file mode 100644 index 0000000..dce5762 --- /dev/null +++ b/api/user/v1/note.go @@ -0,0 +1,85 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// NoteOut 笔记列表项 +type NoteOut struct { + Id int64 `json:"id"` + Title string `json:"title"` + Summary string `json:"summary"` // 正文摘要(列表不返回全文) + Tags []string `json:"tags"` + IsPinned bool `json:"is_pinned"` + RemindAt string `json:"remind_at"` // 空串=未设置 + RemindStatus int `json:"remind_status"` // 0..4 + UpdatedAt string `json:"updated_at"` +} + +type NoteListReq struct { + g.Meta `path:"/note/list" method:"get" tags:"note" summary:"笔记列表"` + Keyword string `json:"keyword"` + Tag string `json:"tag"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type NoteListRes struct { + List []NoteOut `json:"list"` + Total int `json:"total"` + ActiveCount int `json:"active_count"` // 活跃笔记数(不含回收站) + Limit int `json:"limit"` // 免费上限 50;会员为 0(表示不限) + IsMember bool `json:"is_member"` +} + +type NoteDetailReq struct { + g.Meta `path:"/note/detail" method:"get" tags:"note" summary:"笔记详情"` + Id int64 `v:"required|min:1#笔记不存在" json:"id"` +} +type NoteDetailRes struct { + Id int64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Tags []string `json:"tags"` + IsPinned bool `json:"is_pinned"` + RemindAt string `json:"remind_at"` + RemindStatus int `json:"remind_status"` +} + +// NoteSaveReq id=0 表示新建;新建计入额度、编辑不计入 +type NoteSaveReq struct { + g.Meta `path:"/note/save" method:"post" tags:"note" summary:"新建/编辑笔记"` + Id int64 `json:"id"` + Title string `v:"required|length:1,128#标题不能为空|标题最长 128 字" json:"title"` + Content string `v:"length:0,20000#正文过长" json:"content"` + Tags []string `json:"tags"` + IsPinned bool `json:"is_pinned"` + RemindAt string `v:"datetime#提醒时间格式不正确" json:"remind_at"` // 空串=取消提醒 +} +type NoteSaveRes struct { + Id int64 `json:"id"` + ActiveCount int `json:"active_count"` +} + +type NoteDeleteReq struct { + g.Meta `path:"/note/delete" method:"post" tags:"note" summary:"删除笔记(软删,释放额度)"` + Id int64 `v:"required" json:"id"` +} +type NoteDeleteRes struct{} + +type NoteRestoreReq struct { + g.Meta `path:"/note/restore" method:"post" tags:"note" summary:"恢复笔记(P2)"` + Id int64 `v:"required" json:"id"` +} +type NoteRestoreRes = NoteSaveRes + +type NoteRemindCancelReq struct { + g.Meta `path:"/note/remind-cancel" method:"post" tags:"note" summary:"取消提醒"` + Id int64 `v:"required" json:"id"` +} +type NoteRemindCancelRes struct{} + +// NoteSubscribeReportReq 前端 requestSubscribeMessage 授权成功后回传(一次授权=一次可发送) +type NoteSubscribeReportReq struct { + g.Meta `path:"/note/subscribe-report" method:"post" tags:"note" summary:"上报订阅授权次数"` + NoteId int64 `v:"required" json:"note_id"` + Count int `v:"min:1#授权次数异常" json:"count"` +} +type NoteSubscribeReportRes struct{} diff --git a/api/user/v1/order.go b/api/user/v1/order.go new file mode 100644 index 0000000..7d1698c --- /dev/null +++ b/api/user/v1/order.go @@ -0,0 +1,94 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 用户端 · 会员 / 次数包订单(N1 两步式:建单 / 建签名 / 详情 / 应用优惠) +// +// 铁律 S1:响应一律 snake_case。请求沿用 member / quota 组既有字段名(snake_case)。 +// +// 正当例外(非豁免):微信虚拟支付规定字段 signData / paySig / signature / env 由客户端 +// 原样交给 wx.requestVirtualPayment,**必须原样透传、禁止改名**(改名会导致收银台直接失败)。 +// 此类「外部平台规定」的命名不受 S1 约束(见 MemberOrderPayRes)。 +// ============================================================================ + +// MemberOrderPayReq 对已建订单构建支付参数(O2) +type MemberOrderPayReq struct { + g.Meta `path:"/member/order/pay" method:"post" tags:"member" summary:"对已建订单构建支付参数"` + OutTradeNo string `v:"required#订单号不能为空" json:"out_trade_no"` + Code string `v:"required#缺少微信登录凭证" json:"code"` +} + +// MemberOrderPayRes 虚拟支付参数(O2 / O6)。 +// +// ⚠️ SignData / PaySig / Signature / Env 的字段名由微信 wx.requestVirtualPayment 规定, +// 必须原样透传、禁止改动(改名会导致收银台直接失败)。 +type MemberOrderPayRes struct { + OutTradeNo string `json:"out_trade_no"` + Mode string `json:"mode"` // 固定 short_series_goods + SignData string `json:"signData"` + PaySig string `json:"paySig"` + Signature string `json:"signature"` + Env int `json:"env"` +} + +// QuotaOrderPayReq 次数包订单构建支付参数(O6,与会员完全同构) +type QuotaOrderPayReq struct { + g.Meta `path:"/quota/order/pay" method:"post" tags:"quota" summary:"次数包订单构建支付参数"` + OutTradeNo string `v:"required#订单号不能为空" json:"out_trade_no"` + Code string `v:"required#缺少微信登录凭证" json:"code"` +} + +// QuotaOrderPayRes 次数包支付参数(与会员完全同构) +type QuotaOrderPayRes = MemberOrderPayRes + +// MemberOrderDetailReq 订单详情(O3) +type MemberOrderDetailReq struct { + g.Meta `path:"/member/order/detail" method:"get" tags:"member" summary:"订单详情"` + OutTradeNo string `v:"required#订单号不能为空" json:"out_trade_no"` +} + +// MemberOrderDetailRes 订单详情(O3,响应 snake_case)。 +type MemberOrderDetailRes struct { + OutTradeNo string `json:"out_trade_no"` + OrderType int `json:"order_type"` // 1 套餐 2 次数包 + Title string `json:"title"` // 套餐名 / 次数包名 + PlanKey string `json:"plan_key"` + PlanName string `json:"plan_name"` + PackKey string `json:"pack_key"` // 次数包 key(order_type=2 时用于查询可选免单券) + ToolKey string `json:"tool_key"` + ToolName string `json:"tool_name"` + Times int `json:"times"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + DiscountCents int64 `json:"discount_cents"` + PromoKind int `json:"promo_kind"` // 0 无 1 优惠码 2 免单券 + PromoCode string `json:"promo_code"` + UserCouponId int64 `json:"user_coupon_id"` + Status int `json:"status"` // 0 待支付 1 已发货 2 已退款 3 已关闭 + StatusText string `json:"status_text"` + CreatedAt string `json:"created_at"` + DeliveredAt string `json:"delivered_at"` +} + +// MemberOrderApplyVoucherReq 应用 / 更换 / 清除优惠(O4)。 +// +// PromoCode 与 UserCouponId **互斥**:同时传 → 4010(后端校验,不靠前端隐藏);两者皆空 = 清除优惠。 +type MemberOrderApplyVoucherReq struct { + g.Meta `path:"/member/order/apply-voucher" method:"post" tags:"member" summary:"应用/更换/清除优惠"` + OutTradeNo string `v:"required#订单号不能为空" json:"out_trade_no"` + PromoCode string `json:"promo_code"` + UserCouponId int64 `json:"user_coupon_id"` +} + +// MemberOrderApplyVoucherRes 应用优惠结果(O4,响应 snake_case)。 +type MemberOrderApplyVoucherRes struct { + Valid bool `json:"valid"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + DiscountCents int64 `json:"discount_cents"` + PromoKind int `json:"promo_kind"` + PromoCode string `json:"promo_code"` + UserCouponId int64 `json:"user_coupon_id"` + Message string `json:"message"` +} diff --git a/api/user/v1/promo.go b/api/user/v1/promo.go new file mode 100644 index 0000000..a3ee479 --- /dev/null +++ b/api/user/v1/promo.go @@ -0,0 +1,107 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ============================================================================ +// 用户端优惠凭证域契约(优惠码校验 / 我的券 / 券可用性校验 / 当前订单可选用券) +// +// 约定(铁律 S1):请求 camelCase / 响应 **snake_case**。 +// (架构文档 §4.4.3 此处曾把若干出参写成驼峰,已按 S1 纠正。) +// ============================================================================ + +// ===== P1 优惠码校验(服务端为准,前端只展示)===== + +// PromoValidateReq 校验优惠码 +type PromoValidateReq struct { + g.Meta `path:"/promo/validate" method:"post" tags:"promo" summary:"校验优惠码"` + Code string `v:"required#请输入优惠码" json:"code"` + OrderType int `v:"required|in:1,2#订单类型非法" json:"orderType"` // 1会员套餐 2次数包 + TargetKey string `json:"targetKey"` // plan_key 或 pack_key +} +type PromoValidateRes struct { + Valid bool `json:"valid"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + DiscountCents int64 `json:"discount_cents"` + ProductId string `json:"product_id"` + Message string `json:"message"` +} + +// ===== P5 我的优惠券 ===== + +// CouponMyReq 我的优惠券 +type CouponMyReq struct { + g.Meta `path:"/coupon/my" method:"get" tags:"coupon" summary:"我的优惠券"` + Status *int `json:"status"` // 可空:0可用 1已用 2已过期 +} +type CouponMySummary struct { + Usable int `json:"usable"` + Freebie int `json:"freebie"` // 可抵扣券(仅免单券) + ExpiringSoon int `json:"expiring_soon"` // 本月将过期 + Used int `json:"used"` + Expired int `json:"expired"` +} +type UserCouponItem struct { + Id int64 `json:"id"` + CouponId int64 `json:"coupon_id"` + Title string `json:"title"` + SubTitle string `json:"sub_title"` + CouponType int `json:"coupon_type"` // 1免单券 2展示券 + Kind int `json:"kind"` + Value int `json:"value"` + ThresholdCents int `json:"threshold_cents"` + Status int `json:"status"` // 0未使用 1已使用 2已过期 3已作废 + ExpireAt string `json:"expire_at"` + GrantedAt string `json:"granted_at"` + UsedAt string `json:"used_at"` +} +type CouponMyRes struct { + Summary CouponMySummary `json:"summary"` + List []UserCouponItem `json:"list"` +} + +// ===== P6 下单前可用性校验(只读,不占用)===== + +// CouponValidateReq 校验免单券可用性 +type CouponValidateReq struct { + g.Meta `path:"/coupon/validate" method:"post" tags:"coupon" summary:"校验免单券可用性"` + UserCouponId int64 `v:"required" json:"userCouponId"` + OrderType int `v:"required|in:1,2" json:"orderType"` + TargetKey string `json:"targetKey"` +} +type CouponValidateRes struct { + Valid bool `json:"valid"` + CouponType int `json:"coupon_type"` + IsFreebie bool `json:"is_freebie"` + ProductId string `json:"product_id"` + PriceCents int64 `json:"price_cents"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + Message string `json:"message"` +} + +// ===== P7 当前订单可选用的券(只返免单券)===== + +// CouponUsableReq 当前订单可选用的券 +type CouponUsableReq struct { + g.Meta `path:"/coupon/usable" method:"get" tags:"coupon" summary:"当前订单可选用券"` + OrderType int `v:"required|in:1,2" json:"orderType"` + TargetKey string `json:"targetKey"` + // OutTradeNo 可选(向后兼容):传入本人订单号时,响应会**保底**返回该订单当前 + // 已选中的那张免单券并标记 applied=true。原因:下单占用后该券 status 变 1, + // 会被「仅返未使用券」的过滤排除,导致用户只有一张券时列表变空、既看不到也换不了。 + OutTradeNo string `json:"outTradeNo"` +} +type UsableCouponItem struct { + UserCouponId int64 `json:"user_coupon_id"` + Title string `json:"title"` + CouponType int `json:"coupon_type"` + IsFreebie bool `json:"is_freebie"` + ExpireAt string `json:"expire_at"` + EstimatedPaidPriceCents int64 `json:"estimated_paid_price_cents"` + // Applied 是否为「当前订单已选中」的券(默认 false;仅当请求带 outTradeNo 时可能为 true)。 + Applied bool `json:"applied"` +} +type CouponUsableRes struct { + List []UsableCouponItem `json:"list"` +} diff --git a/api/user/v1/quota.go b/api/user/v1/quota.go index 7ce86ff..1470f82 100644 --- a/api/user/v1/quota.go +++ b/api/user/v1/quota.go @@ -87,22 +87,28 @@ type QuotaPacksRes struct { List []QuotaPackOut `json:"list"` } -// QuotaOrderCreateReq 创建次数包订单(支付参数与会员套餐完全同构) +// QuotaOrderCreateReq 创建次数包订单(**只建单**,N1:与会员套餐两步式同款) +// +// Code 保留以兼容旧客户端但**非必传**:建单不再校验「支付人 = 登录人」(该校验前移到 +// POST /quota/order/pay)。两步式的第一步只负责落一条待支付订单。 type QuotaOrderCreateReq struct { - g.Meta `path:"/quota/order" method:"post" tags:"quota" summary:"创建次数包订单(返回支付参数)"` + g.Meta `path:"/quota/order" method:"post" tags:"quota" summary:"创建次数包订单(待支付,不返回支付参数)"` PackKey string `v:"required#请选择次数包" json:"pack_key"` ToolKey string `v:"required#请选择要购买的次数包工具" json:"tool_key"` Code string `json:"code"` } + +// QuotaOrderCreateRes 创建次数包订单响应(只建单,不返回支付参数)。 +// +// 支付参数由 POST /quota/order/pay 构建(见 order.go 的 QuotaOrderPayRes)。 type QuotaOrderCreateRes struct { - OutTradeNo string `json:"out_trade_no"` - Mode string `json:"mode"` - SignData string `json:"signData"` - PaySig string `json:"paySig"` - Signature string `json:"signature"` - Env int `json:"env"` - PackKey string `json:"pack_key"` - ToolKey string `json:"tool_key"` - Times int `json:"times"` - ValidDays int `json:"valid_days"` + OutTradeNo string `json:"out_trade_no"` + Reused bool `json:"reused"` + OriginPriceCents int64 `json:"origin_price_cents"` + PaidPriceCents int64 `json:"paid_price_cents"` + PackKey string `json:"pack_key"` + ToolKey string `json:"tool_key"` + Times int `json:"times"` + ValidDays int `json:"valid_days"` + Status int `json:"status"` } diff --git a/api/user/v1/user.go b/api/user/v1/user.go index bfed214..aabe5d1 100644 --- a/api/user/v1/user.go +++ b/api/user/v1/user.go @@ -76,6 +76,18 @@ type ProfileUpdateRes struct { AvatarUrl string `json:"avatar_url"` } +// ===== 头像上传(N3)===== + +// AvatarUploadReq 头像上传(multipart/form-data,文件字段名 file;≤5MB,jpg / png / webp)。 +// +// 返回的 avatar_url 为「浏览器可直接打开」的绝对地址(:///static/avatar/...)。 +type AvatarUploadReq struct { + g.Meta `path:"/user/avatar" method:"post" mime:"multipart/form-data" tags:"user" summary:"上传头像"` +} +type AvatarUploadRes struct { + AvatarUrl string `json:"avatar_url"` +} + // ===== 等级 ===== type LevelReq struct { diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 46556f6..c001590 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -4,15 +4,30 @@ import ( "context" _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/os/gcron" + "github.com/gogf/gf/v2/os/gfile" "tool-api/internal/controller" "tool-api/internal/logic" ) +// GoFrame gcron 的 pattern 是 **6 段**(秒 分 时 日 月 周),不是标准 crontab 的 5 段。 +// +// ⚠️ 写成 5 段既不会 panic 也不会启动失败,只会让 AddSingleton 返回 error 并被下面的 +// WARNING 吞掉 —— 任务静默不注册、功能完全失效(本项目曾把「笔记提醒扫描」写成 +// "*/1 * * * *" 而无人察觉)。故把 pattern 提为常量,并由 cmd_test.go 固化校验。 +// 若确实要用标准 5 段 crontab,需前置一个 `#`(如 "# */1 * * * *")声明忽略秒。 +const ( + cronPatternSweepOrders = "0 */5 * * * *" // 会员订单扫单:每 5 分钟 + cronPatternScanRemind = "0 */1 * * * *" // 笔记提醒扫描:每 1 分钟 + cronPatternReleaseCoupons = "0 */5 * * * *" // 免单券超时占用释放:每 5 分钟 + cronPatternCloseOrders = "0 */5 * * * *" // 订单超时关闭:每 5 分钟 +) + var ( Main = gcmd.Command{ Name: "main", @@ -25,30 +40,77 @@ var ( logic.Seed(ctx) // 会员订单兜底扫单:推送丢失时主动查单补发货(建议间隔 5 分钟) - if _, err = gcron.AddSingleton(ctx, "0 */5 * * * *", func(c context.Context) { + if _, err = gcron.AddSingleton(ctx, cronPatternSweepOrders, func(c context.Context) { logic.SweepPendingOrders(c) }, "member-order-sweep"); err != nil { g.Log().Warningf(ctx, "[cron] 会员订单扫单任务注册失败: %v", err) + } else { + g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "member-order-sweep", cronPatternSweepOrders) + } + + // 笔记定时提醒扫描:每分钟扫描到点的提醒并发订阅消息(条件更新保证只发一次) + if _, err = gcron.AddSingleton(ctx, cronPatternScanRemind, func(c context.Context) { + logic.ScanDueReminders(c) + }, "note-remind-scan"); err != nil { + g.Log().Warningf(ctx, "[cron] 笔记提醒扫描任务注册失败: %v", err) + } else { + g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "note-remind-scan", cronPatternScanRemind) + } + + // 免单券超时占用释放:订单取消/支付失败/超时(coupon.lock.ttlSeconds)后把券释放回可用 + if _, err = gcron.AddSingleton(ctx, cronPatternReleaseCoupons, func(c context.Context) { + logic.ReleaseExpiredCouponLocks(c) + }, "coupon-lock-release"); err != nil { + g.Log().Warningf(ctx, "[cron] 免单券释放任务注册失败: %v", err) + } else { + g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "coupon-lock-release", cronPatternReleaseCoupons) + } + + // 订单超时关闭:把超时未支付的待支付单置为已关闭(status=3)并释放其占用中的券。 + // 时限 orderPendingTtlSeconds 与上面「免单券释放」同源(同一 settings 键,D2)。 + if _, err = gcron.AddSingleton(ctx, cronPatternCloseOrders, func(c context.Context) { + logic.ClosePendingOrders(c) + }, "order-close-pending"); err != nil { + g.Log().Warningf(ctx, "[cron] 订单超时关闭任务注册失败: %v", err) + } else { + g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "order-close-pending", cronPatternCloseOrders) } s := g.Server() // 公开接口:小程序登录、开发登录、管理端登录、虚拟支付发货推送回调 s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(logic.CORS, logic.Response) - group.Bind(controller.UserPub, controller.AdminPub) + group.Bind(controller.UserPub, controller.AdminPub, controller.EventPub) // 平台回调无 token,单独注册(响应体为 XML,不走统一 JSON 包装) group.POST("/pay/notify", controller.PayNotify) }) // 小程序用户接口 s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(logic.CORS, logic.Response, logic.UserAuth) - group.Bind(controller.UserAuth, controller.Member, controller.Quota) + group.Bind(controller.UserAuth, controller.Member, controller.Quota, controller.Note, + controller.Promo, controller.Coupon) }) - // 管理端接口 + // 管理端接口:鉴权(AdminAuth)+ 权限分级(AdminPerm,按角色权限点放行) s.Group("/", func(group *ghttp.RouterGroup) { - group.Middleware(logic.CORS, logic.Response, logic.AdminAuth) - group.Bind(controller.AdminAuth) + group.Middleware(logic.CORS, logic.Response, logic.AdminAuth, logic.AdminPerm) + group.Bind(controller.AdminAuth, controller.Event, controller.AdminPromo, controller.AdminCoupon, controller.AdminOrder) }) + // 活动小程序码静态资源(生成时落在 manifest/qrcode,供管理端/前端访问) + // ⚠️ AddStaticPath 在目录不存在时**直接 FATAL 退出、不降级**,而该目录原先只在 + // 生成码图时才惰性创建 → 首次启动必然崩在「目录还没被创建」上(先有鸡先有蛋)。 + // 故此处先幂等建目录(gfile.Mkdir = os.MkdirAll,已存在返回 nil)再注册。 + if err = gfile.Mkdir(logic.QrCodeDir); err != nil { + return gerror.Wrapf(err, "预创建码图目录 %s 失败(静态路由 /qrcode 无法注册)", logic.QrCodeDir) + } + s.AddStaticPath("/qrcode", logic.QrCodeDir) + + // 用户头像静态资源(N3):落盘于 manifest/static/avatar/...,由 /static 提供访问。 + // 与 /qrcode 同一套坑与治法:先幂等建目录再注册静态路由(AddStaticPath 目录不存在会 FATAL)。 + if err = gfile.Mkdir(logic.StaticDir); err != nil { + return gerror.Wrapf(err, "预创建静态目录 %s 失败(静态路由 /static 无法注册)", logic.StaticDir) + } + s.AddStaticPath("/static", logic.StaticDir) + s.Run() return nil }, diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go new file mode 100644 index 0000000..3807573 --- /dev/null +++ b/internal/cmd/cmd_test.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "context" + "os" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcron" + "github.com/gogf/gf/v2/os/gfile" + + "tool-api/internal/logic" +) + +// ============================================================================ +// 启动期(cmd.Main.Func)两条易静默失效/直接崩的路径,各自固化一个用例。 +// +// 背景:这两条都是「本地跑起来才发现」的问题,都不会被业务单测覆盖 —— +// ① cron pattern 写错 → AddSingleton 返回 error 被 WARNING 吞掉,任务静默不注册; +// ② 静态目录不存在 → AddStaticPath 调 Logger().Fatalf → os.Exit(1),进程直接死。 +// ============================================================================ + +// TestCronPatternsAreValid 固化启动期两个定时任务必须注册成功。 +// +// GoFrame gcron 的 pattern 是 **6 段**(秒 分 时 日 月 周),不是标准 crontab 的 5 段。 +// 本项目曾把「笔记提醒扫描」写成 5 段 "*/1 * * * *": +// AddSingleton 返回 error → 被启动日志里的 WARNING 吞掉 → 服务照常启动、无人察觉, +// 但该任务从未注册、笔记定时提醒功能完全失效。 +func TestCronPatternsAreValid(t *testing.T) { + patterns := map[string]string{ + "member-order-sweep": cronPatternSweepOrders, + "note-remind-scan": cronPatternScanRemind, + "coupon-lock-release": cronPatternReleaseCoupons, + "order-close-pending": cronPatternCloseOrders, + } + for name, pattern := range patterns { + name, pattern := name, pattern + t.Run(name, func(t *testing.T) { + if _, err := gcron.AddSingleton( + context.Background(), pattern, func(ctx context.Context) {}, name, + ); err != nil { + t.Fatalf("cron 任务 %s 注册失败,pattern=%q:%v\n"+ + "(gcron 需要 6 段「秒 分 时 日 月 周」;标准 5 段需前置 '#')", name, pattern, err) + } + gcron.Remove(name) + }) + } +} + +// TestFivePartCronPatternIsRejected 反证上面的用例不是空转: +// 标准 5 段 crontab 写法确实会被 gcron 拒绝。 +// 若哪天这条断言失效(gcron 升级后支持了 5 段),说明本文件的假设已过期,需重新核对。 +func TestFivePartCronPatternIsRejected(t *testing.T) { + const bad = "*/1 * * * *" + if _, err := gcron.AddSingleton( + context.Background(), bad, func(ctx context.Context) {}, "probe-bad-5part", + ); err == nil { + t.Fatalf("5 段 pattern %q 竟被 gcron 接受,本文件假设已失效,请重新核对 gcron 行为", bad) + } + gcron.Remove("probe-bad-5part") +} + +// TestQrCodeStaticPathBootOrder 固化「静态目录必须先存在」这一启动约束。 +// +// 机理(已读 gf 源码确认,net/ghttp/ghttp_server_config_static.go:93): +// +// if p, err := gfile.Search(path); err != nil { +// s.Logger().Fatalf(ctx, `AddStaticPath failed: %+v`, err) +// } +// +// 是 Fatalf → os.Exit(1),**不是 panic、无法 recover**。 +// 而 manifest/qrcode 原先只在「生成小程序码」时才惰性创建 → +// 全新环境首次启动必然崩在注册静态路由这一步(先有鸡先有蛋的死锁)。 +// 修法:cmd.go 先 gfile.Mkdir(logic.QrCodeDir) 再 AddStaticPath。 +func TestQrCodeStaticPathBootOrder(t *testing.T) { + chdirRepoRoot(t) + + // ① 目录不存在时 gfile.Search 会报错(这就是上面 Fatalf 的触发条件)。 + // 用 Search 探测而不真的调 AddStaticPath —— 后者会直接 os.Exit(1) 杀掉测试进程。 + t.Run("missing_dir_makes_gf_search_fail", func(t *testing.T) { + if _, err := gfile.Search("manifest/__definitely_absent__"); err == nil { + t.Error("gfile.Search 对不存在的目录竟返回成功,本用例假设已失效(gf 行为可能已变更)") + } + }) + + // ② 预创建目录后,AddStaticPath 可以正常注册(即 cmd.go 的修复路径)。 + // AddStaticPath 无返回值,能走到下一行即为通过。 + t.Run("precreated_dir_registers_ok", func(t *testing.T) { + if err := gfile.Mkdir(logic.QrCodeDir); err != nil { + t.Fatalf("gfile.Mkdir(%q) 失败:%v", logic.QrCodeDir, err) + } + if !gfile.Exists(logic.QrCodeDir) { + t.Fatalf("Mkdir 之后目录仍不存在:%q", logic.QrCodeDir) + } + g.Server("static-path-probe").AddStaticPath("/qrcode", logic.QrCodeDir) + }) +} + +// chdirRepoRoot 把工作目录切到仓库根目录。 +// go test 的 CWD 是「包目录」(internal/cmd),直接用相对路径会跑偏到 internal/cmd/manifest/qrcode, +// 与被测代码在真实运行时的 CWD(仓库根)不一致。 +func chdirRepoRoot(t *testing.T) { + t.Helper() + old, err := os.Getwd() + if err != nil { + t.Fatalf("获取当前工作目录失败:%v", err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + + dir := old + for i := 0; i < 8; i++ { + if gfile.Exists(gfile.Join(dir, "go.mod")) { + if err = os.Chdir(dir); err != nil { + t.Fatalf("切换到仓库根目录 %s 失败:%v", dir, err) + } + return + } + dir = gfile.Dir(dir) + } + t.Fatal("向上 8 层未找到 go.mod,无法定位仓库根目录") +} diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 98b97dc..3e0cd80 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -11,13 +11,44 @@ const ( CtxUserId = "userId" CtxLevelKey = "levelKey" CtxAdminId = "adminId" + + // 管理员审计上下文(AdminPerm 中间件写入,WriteAudit 读取) + CtxAdminRole = "adminRole" + CtxAdminAccount = "adminAccount" + CtxAdminName = "adminName" +) + +// 管理员角色(admin_users.role_value) +const ( + AdminRoleSuper = "super" // 超管:全部权限 + AdminRoleOperator = "operator" // 运营:内容读写,不含管理员管理与审计 + AdminRoleReadonly = "readonly" // 只读:仅可查看 +) + +// 管理员状态(admin_users.status) +const ( + AdminStatusDisabled = 0 + AdminStatusEnabled = 1 +) + +// 审计结果(admin_audit_logs.result) +const ( + AuditResultFail = 0 // 失败 + AuditResultSuccess = 1 // 成功 ) // 业务错误码(响应 code 字段) const ( - CodeLocked = 4001 // 模块未授权(等级锁定) - CodeQuotaExhausted = 4002 // 额度用尽(免费/会员/付费额度都已耗尽) - CodePayFail = 4101 // 下单/支付参数异常 + CodeLocked = 4001 // 模块未授权(等级锁定) + CodeQuotaExhausted = 4002 // 额度用尽(免费/会员/付费额度都已耗尽) + CodeNoteLimit = 4003 // 笔记数量超上限(免费版最多 50 条) + CodeNoteNotFound = 4004 // 笔记不存在/无权 + CodeEventUnavailable = 4005 // 活动不存在或未发布 + CodeSeatRateLimited = 4006 // 查座频率超限 + CodeSeatCooling = 4007 // 查座连续失败冷却中 + CodeImportConflict = 4008 // 导入存在未处理冲突 + CodeLayoutConflict = 4009 // 布局版本冲突(CAS 失败) + CodePayFail = 4101 // 下单/支付参数异常 ) // 默认等级:会员到期后回落的目标等级 @@ -32,6 +63,7 @@ const ( TableUsageLogs = "tool_usage_logs" TableWorkbench = "user_workbench" TableAdminUsers = "admin_users" + TableAdminAuditLog = "admin_audit_logs" TableFeedbacks = "feedbacks" TableMemberPlans = "member_plans" TableMemberOrders = "member_orders" @@ -39,14 +71,229 @@ const ( TableUserToolQuota = "user_tool_quota" TableQuotaUsage = "user_quota_usage" TableSettings = "settings" + TableNotes = "notes" + + // 年会域(T08 建前 6 张;T09/T10 分别追加 event_import_logs / event_seat_queries) + TableEnterprises = "enterprises" + TableEmployees = "employees" + TableAnnualEvents = "annual_events" + TableEventParticipants = "event_participants" + TableEventTables = "event_tables" + TableEventSeats = "event_seats" + TableEventImportLogs = "event_import_logs" + TableEventSeatQueries = "event_seat_queries" ) +// 年会活动状态(annual_events.status,字符串枚举;仅 published 可查座) +const ( + EventStatusDraft = "draft" + EventStatusPublished = "published" + EventStatusEnded = "ended" +) + +// 自动排座模式(/event/admin/seat/auto 的 mode) +const ( + SeatModeFill = "fill" // 增量补位:保留手工号,只填未分配员工到空位 + SeatModeReorder = "reorder" // 全部重排:覆盖含手工号在内的编号并清空 is_manual +) + +// 桌型(event_tables.table_type,N4) +// +// 0 普通桌 / 1 主桌 / 2 签到台 / 3 媒体席 / 4 备用桌 / 9 其他。 +// 类型默认联动(前端 N4-10,后端中立存储;老数据缺列回退 0): +// +// 签到台/媒体席/备用桌 → capacity=0 + exclude_auto=1 +// 普通桌 → capacity=10 + exclude_auto=0 +// 主桌 → capacity=10 + exclude_auto=1 +const ( + TableTypeNormal = 0 // 普通桌 + TableTypeMain = 1 // 主桌 + TableTypeCheckin = 2 // 签到台 + TableTypeMedia = 3 // 媒体席 + TableTypeSpare = 4 // 备用桌 + TableTypeOther = 9 // 其他 +) + +// 员工导入:行分类(预览 type,见 §8.4) +const ( + ImportTypeNew = "new" // 新号码 → 新增 + ImportTypeUpdate = "update" // 同手机号 → 视为同一人,更新 + ImportTypeConflict = "conflict" // 同名不同号 → 需人工裁决 + ImportTypeError = "error" // 缺手机号/格式非法 → 跳过(计入 skipped) +) + +// 员工导入:冲突裁决动作(decisions.action,见 §8.4) +const ( + ImportActionMerge = "merge" // 视为同一人·更新手机号 + ImportActionCreate = "create" // 视为另一个人新增 + ImportActionIgnore = "ignore" // 忽略 +) + +// 导入记录状态(event_import_logs.status,见 §8.4) +const ( + ImportStatusDraft = "draft" // 预览中 + ImportStatusCommitted = "committed" // 已入库 + ImportStatusCancelled = "cancelled" // 已取消 +) + +// 导入行数上限(超出给明确提示;可用 settings 覆盖) +const ( + SettingImportRowLimit = "event_import_row_limit" // settings 键 + // DefaultImportRowLimit 单次导入行数上限兜底值 + DefaultImportRowLimit = 2000 +) + +// 查座限流/冷却阈值(settings 键 + 缺省值;无 Redis,以 event_seat_queries 时间窗为准) +const ( + SettingSeatRateLimitPerMin = "event.seat.rateLimitPerMin" // ≥N 次/分钟 → 4006 + SettingSeatRateLimitPerHour = "event.seat.rateLimitPerHour" // ≥N 次/小时 → 4006 + SettingSeatFailCooldownMinutes = "event.seat.failCooldownMinutes" // 冷却时长(分钟) + SettingSeatFailThreshold = "event.seat.failThreshold" // 连续失败达到该值触发冷却 + + DefaultSeatRateLimitPerMin = 5 // PRD-02 §4.2.3 建议值 + DefaultSeatRateLimitPerHour = 20 // 建议值 + DefaultSeatFailCooldownMinutes = 10 // 建议值 + DefaultSeatFailThreshold = 5 // 建议值 +) + +// 查座审计结果枚举(event_seat_queries.result,架构 §3.2.9) +const ( + SeatResultFail = 0 // 失败(未命中/未分配) + SeatResultSuccess = 1 // 成功(命中并返回本人桌座) + SeatResultAll = -1 // 审计列表筛选用:全部结果(仅用于入参,不落库) +) + +// 笔记额度与提醒状态 +const ( + // NoteFreeLimit 免费用户活跃笔记数上限(总量上限,非每月重置) + NoteFreeLimit = 50 + + // notes.remind_status 状态枚举 + NoteRemindNone = 0 // 未设置 + NoteRemindPending = 1 // 待提醒 + NoteRemindSent = 2 // 已提醒 + NoteRemindFailed = 3 // 发送失败(含未发送·授权不足 / 模板未配置) + NoteRemindExpired = 4 // 已过期 +) + +// 笔记提醒过期设置(settings 表) +const ( + SettingNoteRemindExpireHours = "note_remind_expire_hours" // 待提醒超过该小时数仍未发送则置「已过期」 +) + +// DefaultNoteRemindExpireHours 提醒过期兜底值:settings 未配置时使用。 +const DefaultNoteRemindExpireHours = 24 + // 订单类型:同一个下单/发货链路承载「会员」与「次数包」两种商品 const ( OrderTypeMember = 1 // 会员套餐(延长等级有效期) OrderTypeQuota = 2 // 次数包(增加某工具的付费额度余额) ) +// ============================================================================ +// 商业化域:优惠码 / 优惠券(迭代-2026-09-20) +// ============================================================================ + +// 表名(优惠码 / 券模板 / 用户持有券) +const ( + TablePromoCodes = "promo_codes" + TableCoupons = "coupons" + TableUserCoupons = "user_coupons" +) + +// 业务错误码:优惠凭证域 +// +// 编号接续 4001–4009 / 4010–4014 段(架构 §4.5 统一裁定,解决两份 PRD 的冲突)。 +const ( + CodePromoInvalid = 4010 // 优惠码无效/过期/达上限/超单人限用/不适用;或同时传 promo_code 与 user_coupon_id + CodeCouponUnavailable = 4011 // 券不可用/已用/过期/作废;免单券并发占用失败 + CodeLevelInvalid = 4012 // 等级不存在或已停用 + CodePromoRateLimited = 4013 // 同一用户 1 分钟内优惠码错误次数超限 + CodeProductPriceMismatch = 4014 // 服务端自检:settings 道具价格与 goodsPrice 预期不一致 +) + +// 业务错误码(续 4015–4018 段) +// +// 4015 属 T01(订单两步式):订单不存在 / 不属于当前用户 / 状态不允许该操作。 +// 4016/4017(T02 头像上传)见紧随其后的独立区块;4018 属 T04(座次自动排座前置条件)。 +const ( + CodeOrderInvalid = 4015 // 订单不存在 / 不属于当前用户 / 状态不允许该操作(已支付、已关闭) + CodeSeatAutoInvalid = 4018 // 自动排座前置条件不满足(无可参与排座的桌)——fail-closed,message 列出被排除的桌 +) + +// 头像上传错误码(T02 / N3) +// +// 4017 上传失败**必须给可读中文提示**(如「头像上传失败,请重试」)且**原头像不变、不静默失败**(U1)。 +const ( + CodeUploadInvalid = 4016 // 上传校验失败:体积超限(>5MB)/ 格式不支持 —— 前端不发起上传 + CodeUploadFailed = 4017 // 上传存储失败(服务端写盘 / 目录异常)—— 可读中文提示,原头像不变 +) + +// 券型(coupons.coupon_type / user_coupons.coupon_type) +// +// 1 免单券(走支付)/ 2 展示券(仅展示)。**新建默认 2**(fail-closed)。 +const ( + CouponTypeFreebie = 1 + CouponTypeDisplay = 2 +) + +// 券面语义 kind(coupons.kind / user_coupons.kind):1 满减 / 2 免单 +const ( + CouponKindCut = 1 + CouponKindFreebie = 2 +) + +// 用户券状态(user_coupons.status) +const ( + UserCouponStatusUnused = 0 // 未使用 + UserCouponStatusUsed = 1 // 已使用(占用或核销) + UserCouponStatusExpired = 2 // 已过期 + UserCouponStatusVoid = 3 // 已作废 +) + +// 用户券来源(user_coupons.source) +const ( + UserCouponSourceAdmin = 1 // 后台发放 + UserCouponSourceClaim = 2 // 活动领取 + UserCouponSourceSystem = 3 // 系统赠送 +) + +// 用户券占用/核销人(user_coupons.used_by) +const ( + UserCouponUsedBySystem = "system" // 订单占用 + UserCouponUsedByAdmin = "admin" // 人工核销 +) + +// 订单使用凭证类型(member_orders.promo_kind) +const ( + PromoKindNone = 0 // 未使用凭证 + PromoKindCode = 1 // 优惠码 + PromoKindCoupon = 2 // 免单券 +) + +// 适用范围(promo_codes.scope / coupons.scope):0 全部 / 1 指定会员套餐 / 2 指定次数包 +const ( + PromoScopeAll = 0 + PromoScopePlan = 1 + PromoScopePack = 2 +) + +// 优惠凭证域 settings 键(后台可配,不硬编码进 config.yaml) +const ( + SettingFreebieProductId = "pay.freebie.product_id" // 免单道具 ID + SettingFreebiePriceCents = "pay.freebie.price_cents" // 免单道具价格(分) + SettingPromoFailLimitPerMin = "promo.fail.limitPerMin" // 同一用户 1 分钟错误上限 + SettingCouponLockTtlSeconds = "coupon.lock.ttlSeconds" // 免单券占用自动释放时长(秒) +) + +// 优惠凭证域 settings 兜底值(settings 未配置时使用) +const ( + DefaultFreebieProductId = "admin_1" + DefaultFreebiePriceCents = 1 + DefaultPromoFailLimitPerMin = 6 + DefaultCouponLockTtlSeconds = 900 +) + // 额度来源(扣减时实际命中的池) const ( QuotaSourceFree = "free" // 免费额度 diff --git a/internal/controller/admin.go b/internal/controller/admin.go index 76b517f..0ee4700 100644 --- a/internal/controller/admin.go +++ b/internal/controller/admin.go @@ -31,13 +31,27 @@ func (c *cAdminAuth) UserList(ctx context.Context, req *v1.UserListReq) (res *v1 } func (c *cAdminAuth) UserSetLevel(ctx context.Context, req *v1.UserSetLevelReq) (res *v1.UserSetLevelRes, err error) { - return logic.AdminUserSetLevel(ctx, req.UserId, req.LevelKey) + return logic.AdminUserSetLevel(ctx, req.UserId, req.LevelKey, req.ExpireAt, req.Remark) +} + +func (c *cAdminAuth) UserDetail(ctx context.Context, req *v1.UserDetailReq) (res *v1.UserDetailRes, err error) { + return logic.AdminUserDetail(ctx, req.UserId) } func (c *cAdminAuth) UserStatus(ctx context.Context, req *v1.UserStatusReq) (res *v1.UserStatusRes, err error) { return logic.AdminUserStatus(ctx, req.UserId, req.Status) } +// ===== 批量操作(T15)===== + +func (c *cAdminAuth) UserBatch(ctx context.Context, req *v1.UserBatchReq) (res *v1.UserBatchRes, err error) { + return logic.AdminUserBatch(ctx, req) +} + +func (c *cAdminAuth) ToolsBatch(ctx context.Context, req *v1.ToolsBatchReq) (res *v1.ToolsBatchRes, err error) { + return logic.AdminToolsBatch(ctx, req) +} + func (c *cAdminAuth) LevelList(ctx context.Context, req *v1.LevelListReq) (res *v1.LevelListRes, err error) { return logic.AdminLevelList(ctx) } @@ -111,3 +125,25 @@ func (c *cAdminAuth) PlanSave(ctx context.Context, req *v1.PlanSaveReq) (res *v1 func (c *cAdminAuth) UserQuotaPeriod(ctx context.Context, req *v1.UserQuotaPeriodReq) (res *v1.UserQuotaPeriodRes, err error) { return logic.AdminUserQuotaPeriod(ctx, req.UserId, req.PeriodDays) } + +// ===== 管理员管理 + 操作审计(T17)===== + +func (c *cAdminAuth) AdminList(ctx context.Context, req *v1.AdminListReq) (res *v1.AdminListRes, err error) { + return logic.AdminList(ctx) +} + +func (c *cAdminAuth) AdminSave(ctx context.Context, req *v1.AdminSaveReq) (res *v1.AdminSaveRes, err error) { + return logic.AdminSave(ctx, req) +} + +func (c *cAdminAuth) AdminStatus(ctx context.Context, req *v1.AdminStatusReq) (res *v1.AdminStatusRes, err error) { + return logic.AdminStatus(ctx, req.Id, req.Status) +} + +func (c *cAdminAuth) AuditList(ctx context.Context, req *v1.AuditListReq) (res *v1.AuditListRes, err error) { + return logic.AdminAuditList(ctx, req) +} + +func (c *cAdminAuth) AuditRecord(ctx context.Context, req *v1.AuditRecordReq) (res *v1.AuditRecordRes, err error) { + return logic.AdminAuditRecord(ctx, req) +} diff --git a/internal/controller/admin_order.go b/internal/controller/admin_order.go new file mode 100644 index 0000000..3f544d6 --- /dev/null +++ b/internal/controller/admin_order.go @@ -0,0 +1,24 @@ +package controller + +import ( + "context" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/logic" +) + +// AdminOrder 管理端订单管理 + 退款接口 +type cAdminOrder struct{} + +// AdminOrder 订单域控制器(注册于管理端 Group:AdminAuth + AdminPerm 之后) +var AdminOrder = &cAdminOrder{} + +// OrderList 订单列表 +func (c *cAdminOrder) OrderList(ctx context.Context, req *v1.OrderListReq) (res *v1.OrderListRes, err error) { + return logic.AdminOrderList(ctx, req) +} + +// OrderRefund 订单退款(可选回收所购服务) +func (c *cAdminOrder) OrderRefund(ctx context.Context, req *v1.OrderRefundReq) (res *v1.OrderRefundRes, err error) { + return logic.AdminOrderRefund(ctx, req.OutTradeNo, req.RevokeService) +} diff --git a/internal/controller/coupon.go b/internal/controller/coupon.go new file mode 100644 index 0000000..4596d83 --- /dev/null +++ b/internal/controller/coupon.go @@ -0,0 +1,68 @@ +package controller + +import ( + "context" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 优惠券控制器 +// +// 拆成两个对象分别挂到 UserAuth / AdminAuth 两组: +// - Coupon 用户端(P5 /coupon/my、P6 /coupon/validate、P7 /coupon/usable) +// - AdminCoupon 管理端(A6–A11) +// ============================================================================ + +// cCoupon 用户端优惠券接口(需用户登录) +type cCoupon struct{} + +// cAdminCoupon 管理端优惠券接口(需管理员登录) +type cAdminCoupon struct{} + +var ( + Coupon = &cCoupon{} + AdminCoupon = &cAdminCoupon{} +) + +// ===== 用户端 ===== + +func (c *cCoupon) CouponMy(ctx context.Context, req *userv1.CouponMyReq) (res *userv1.CouponMyRes, err error) { + return logic.CouponMy(ctx, req.Status) +} + +func (c *cCoupon) CouponValidate(ctx context.Context, req *userv1.CouponValidateReq) (res *userv1.CouponValidateRes, err error) { + return logic.CouponValidate(ctx, req.UserCouponId, req.OrderType, req.TargetKey) +} + +func (c *cCoupon) CouponUsable(ctx context.Context, req *userv1.CouponUsableReq) (res *userv1.CouponUsableRes, err error) { + return logic.CouponUsable(ctx, req.OrderType, req.TargetKey, req.OutTradeNo) +} + +// ===== 管理端 ===== + +func (c *cAdminCoupon) CouponTemplateList(ctx context.Context, req *adminv1.CouponTemplateListReq) (res *adminv1.CouponTemplateListRes, err error) { + return logic.AdminCouponTemplateList(ctx, req) +} + +func (c *cAdminCoupon) CouponTemplateSave(ctx context.Context, req *adminv1.CouponTemplateSaveReq) (res *adminv1.CouponTemplateSaveRes, err error) { + return logic.AdminCouponTemplateSave(ctx, req) +} + +func (c *cAdminCoupon) CouponTemplateToggle(ctx context.Context, req *adminv1.CouponTemplateToggleReq) (res *adminv1.CouponTemplateToggleRes, err error) { + return logic.AdminCouponTemplateToggle(ctx, req.Id, req.Status) +} + +func (c *cAdminCoupon) CouponGrant(ctx context.Context, req *adminv1.CouponGrantReq) (res *adminv1.CouponGrantRes, err error) { + return logic.AdminCouponGrant(ctx, req) +} + +func (c *cAdminCoupon) CouponRecordList(ctx context.Context, req *adminv1.CouponRecordListReq) (res *adminv1.CouponRecordListRes, err error) { + return logic.AdminCouponRecordList(ctx, req) +} + +func (c *cAdminCoupon) CouponRedeem(ctx context.Context, req *adminv1.CouponRedeemReq) (res *adminv1.CouponRedeemRes, err error) { + return logic.AdminCouponRedeem(ctx, req.UserCouponId, req.Remark) +} diff --git a/internal/controller/event.go b/internal/controller/event.go new file mode 100644 index 0000000..5e2427a --- /dev/null +++ b/internal/controller/event.go @@ -0,0 +1,116 @@ +package controller + +import ( + "context" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/logic" +) + +// cEvent 管理端「年会座次」控制器(绑定在 AdminAuth 分组下)。 +// +// 注意:所有方法必须是标准签名 func(ctx, req) (res, err)。 +// 不要写 func(*ghttp.Request) 签名的方法 —— GoFrame 的 group.Bind 会把这类方法 +// 一并自动注册成路由(见 controller/member.go PayNotify 注释、架构 §9.1 R3)。 +type cEvent struct{} + +// Event 年会座次控制器 +var Event = &cEvent{} + +// ===== 企业 ===== + +func (c *cEvent) EnterpriseList(ctx context.Context, req *v1.EnterpriseListReq) (res *v1.EnterpriseListRes, err error) { + return logic.AdminEnterpriseList(ctx, req) +} + +func (c *cEvent) EnterpriseSave(ctx context.Context, req *v1.EnterpriseSaveReq) (res *v1.EnterpriseSaveRes, err error) { + return logic.AdminEnterpriseSave(ctx, req) +} + +// ===== 员工 ===== + +func (c *cEvent) EmployeeList(ctx context.Context, req *v1.EmployeeListReq) (res *v1.EmployeeListRes, err error) { + return logic.AdminEmployeeList(ctx, req) +} + +func (c *cEvent) EmployeeSave(ctx context.Context, req *v1.EmployeeSaveReq) (res *v1.EmployeeSaveRes, err error) { + return logic.AdminEmployeeSave(ctx, req) +} + +func (c *cEvent) EmployeeStatus(ctx context.Context, req *v1.EmployeeStatusReq) (res *v1.EmployeeStatusRes, err error) { + return logic.AdminEmployeeStatus(ctx, req) +} + +func (c *cEvent) EmployeeBatch(ctx context.Context, req *v1.EmployeeBatchReq) (res *v1.EmployeeBatchRes, err error) { + return logic.AdminEmployeeBatch(ctx, req) +} + +func (c *cEvent) ParticipantExclude(ctx context.Context, req *v1.ParticipantExcludeReq) (res *v1.ParticipantExcludeRes, err error) { + return logic.AdminParticipantExclude(ctx, req) +} + +// ===== 活动 ===== + +func (c *cEvent) EventList(ctx context.Context, req *v1.EventListReq) (res *v1.EventListRes, err error) { + return logic.AdminEventList(ctx, req) +} + +func (c *cEvent) EventSave(ctx context.Context, req *v1.EventSaveReq) (res *v1.EventSaveRes, err error) { + return logic.AdminEventSave(ctx, req) +} + +func (c *cEvent) EventStatus(ctx context.Context, req *v1.EventStatusReq) (res *v1.EventStatusRes, err error) { + return logic.AdminEventStatus(ctx, req) +} + +func (c *cEvent) EventDetail(ctx context.Context, req *v1.EventDetailReq) (res *v1.EventDetailRes, err error) { + return logic.AdminEventDetail(ctx, req) +} + +func (c *cEvent) EventLayoutSave(ctx context.Context, req *v1.EventLayoutSaveReq) (res *v1.EventLayoutSaveRes, err error) { + return logic.AdminEventLayoutSave(ctx, req) +} + +// ===== 桌 / 座 ===== + +func (c *cEvent) TableSave(ctx context.Context, req *v1.TableSaveReq) (res *v1.TableSaveRes, err error) { + return logic.AdminTableSave(ctx, req) +} + +func (c *cEvent) TableDelete(ctx context.Context, req *v1.TableDeleteReq) (res *v1.TableDeleteRes, err error) { + return logic.AdminTableDelete(ctx, req) +} + +func (c *cEvent) SeatSave(ctx context.Context, req *v1.SeatSaveReq) (res *v1.SeatSaveRes, err error) { + return logic.AdminSeatSave(ctx, req) +} + +func (c *cEvent) SeatAuto(ctx context.Context, req *v1.SeatAutoReq) (res *v1.SeatAutoRes, err error) { + return logic.AdminSeatAuto(ctx, req) +} + +// ===== 员工导入(两段式)===== + +func (c *cEvent) ImportPreview(ctx context.Context, req *v1.ImportPreviewReq) (res *v1.ImportPreviewRes, err error) { + return logic.AdminImportPreview(ctx, req) +} + +func (c *cEvent) ImportConfirm(ctx context.Context, req *v1.ImportConfirmReq) (res *v1.ImportConfirmRes, err error) { + return logic.AdminImportConfirm(ctx, req) +} + +func (c *cEvent) ImportLogList(ctx context.Context, req *v1.ImportLogListReq) (res *v1.ImportLogListRes, err error) { + return logic.AdminImportLogList(ctx, req) +} + +// ===== 活动小程序码 ===== + +func (c *cEvent) EventQrcode(ctx context.Context, req *v1.EventQrcodeReq) (res *v1.EventQrcodeRes, err error) { + return logic.AdminEventQrcode(ctx, req) +} + +// ===== 查座审计(A19)===== + +func (c *cEvent) EventQueryLog(ctx context.Context, req *v1.EventQueryLogReq) (res *v1.EventQueryLogRes, err error) { + return logic.AdminEventQueryLog(ctx, req) +} diff --git a/internal/controller/event_pub.go b/internal/controller/event_pub.go new file mode 100644 index 0000000..49d37f3 --- /dev/null +++ b/internal/controller/event_pub.go @@ -0,0 +1,31 @@ +package controller + +import ( + "context" + + v1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// cEventPub 小程序端「年会」公开接口(免登录)。 +// +// ⚠️ 挂到 cmd.go 的**公开组**(与 UserPub/AdminPub 同组)。该组最容易踩 group.Bind 自动注册坑: +// GoFrame 会把控制器上所有 func(*ghttp.Request) 签名的方法也自动注册成路由。 +// → 本控制器**只有**标准签名方法 func(ctx, req) (res, err),不含任何 func(*ghttp.Request)。 +type cEventPub struct{} + +// EventPub 公开查座控制器 +var EventPub = &cEventPub{} + +func (c *cEventPub) EventInfo(ctx context.Context, req *v1.EventInfoReq) (res *v1.EventInfoRes, err error) { + return logic.EventInfo(ctx, req) +} + +func (c *cEventPub) EventSeatQuery(ctx context.Context, req *v1.EventSeatQueryReq) (res *v1.EventSeatQueryRes, err error) { + return logic.EventSeatQuery(ctx, req) +} + +// EventList 公开可查询活动列表(N4-A):只返已发布活动;无已发布 → {list:[]} +func (c *cEventPub) EventList(ctx context.Context, req *v1.EventPublicListReq) (res *v1.EventPublicListRes, err error) { + return logic.EventPublicList(ctx) +} diff --git a/internal/controller/member.go b/internal/controller/member.go index 1096c57..d31d2c0 100644 --- a/internal/controller/member.go +++ b/internal/controller/member.go @@ -20,7 +20,22 @@ func (c *cMember) MemberCenter(ctx context.Context, req *v1.MemberCenterReq) (re } func (c *cMember) MemberOrderCreate(ctx context.Context, req *v1.MemberOrderCreateReq) (res *v1.MemberOrderCreateRes, err error) { - return logic.MemberOrderCreate(ctx, req.PlanKey, req.Code) + return logic.MemberOrderCreate(ctx, req.PlanKey) +} + +// MemberOrderPay 对已建待支付订单构建支付参数(N1 两步式的第二步) +func (c *cMember) MemberOrderPay(ctx context.Context, req *v1.MemberOrderPayReq) (res *v1.MemberOrderPayRes, err error) { + return logic.MemberOrderPay(ctx, req.OutTradeNo, req.Code) +} + +// MemberOrderDetail 订单详情 +func (c *cMember) MemberOrderDetail(ctx context.Context, req *v1.MemberOrderDetailReq) (res *v1.MemberOrderDetailRes, err error) { + return logic.MemberOrderDetail(ctx, req.OutTradeNo) +} + +// MemberOrderApplyVoucher 应用/更换/清除订单优惠(应用时占用,D3) +func (c *cMember) MemberOrderApplyVoucher(ctx context.Context, req *v1.MemberOrderApplyVoucherReq) (res *v1.MemberOrderApplyVoucherRes, err error) { + return logic.MemberOrderApplyVoucher(ctx, req.OutTradeNo, req.PromoCode, req.UserCouponId) } func (c *cMember) MemberOrderCheck(ctx context.Context, req *v1.MemberOrderCheckReq) (res *v1.MemberOrderCheckRes, err error) { diff --git a/internal/controller/note.go b/internal/controller/note.go new file mode 100644 index 0000000..1e0152e --- /dev/null +++ b/internal/controller/note.go @@ -0,0 +1,46 @@ +package controller + +import ( + "context" + + v1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// cNote 笔记域接口(需用户登录,绑定在 UserAuth 分组下)。 +// +// 注意:所有方法必须是标准签名 func(ctx, req) (res, err)。 +// 不要写 func(*ghttp.Request) 签名的方法 —— GoFrame 的 group.Bind 会把这类方法 +// 一并自动注册成路由(见 controller/member.go PayNotify 注释)。 +type cNote struct{} + +// Note 笔记控制器 +var Note = &cNote{} + +func (c *cNote) NoteList(ctx context.Context, req *v1.NoteListReq) (res *v1.NoteListRes, err error) { + return logic.NoteList(ctx, req) +} + +func (c *cNote) NoteDetail(ctx context.Context, req *v1.NoteDetailReq) (res *v1.NoteDetailRes, err error) { + return logic.NoteDetail(ctx, req) +} + +func (c *cNote) NoteSave(ctx context.Context, req *v1.NoteSaveReq) (res *v1.NoteSaveRes, err error) { + return logic.SaveNote(ctx, req) +} + +func (c *cNote) NoteDelete(ctx context.Context, req *v1.NoteDeleteReq) (res *v1.NoteDeleteRes, err error) { + return logic.DeleteNote(ctx, req) +} + +func (c *cNote) NoteRestore(ctx context.Context, req *v1.NoteRestoreReq) (res *v1.NoteRestoreRes, err error) { + return logic.RestoreNote(ctx, req) +} + +func (c *cNote) NoteRemindCancel(ctx context.Context, req *v1.NoteRemindCancelReq) (res *v1.NoteRemindCancelRes, err error) { + return logic.CancelRemind(ctx, req) +} + +func (c *cNote) NoteSubscribeReport(ctx context.Context, req *v1.NoteSubscribeReportReq) (res *v1.NoteSubscribeReportRes, err error) { + return logic.ReportSubscribe(ctx, req) +} diff --git a/internal/controller/promo.go b/internal/controller/promo.go new file mode 100644 index 0000000..5a95fbc --- /dev/null +++ b/internal/controller/promo.go @@ -0,0 +1,55 @@ +package controller + +import ( + "context" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/logic" +) + +// ============================================================================ +// 优惠码控制器 +// +// 拆成两个对象分别挂到 UserAuth / AdminAuth 两组: +// - Promo 用户端(P1 /promo/validate) +// - AdminPromo 管理端(A1–A5) +// +// ⚠️ 不能把同一个对象同时 Bind 到两组:group.Bind 会把该对象的**全部**方法都注册到该组, +// 导致管理端方法暴露在用户组(或反之)。 +// ============================================================================ + +// cPromo 用户端优惠码接口(需用户登录) +type cPromo struct{} + +// cAdminPromo 管理端优惠码接口(需管理员登录) +type cAdminPromo struct{} + +var ( + Promo = &cPromo{} + AdminPromo = &cAdminPromo{} +) + +func (c *cPromo) PromoValidate(ctx context.Context, req *userv1.PromoValidateReq) (res *userv1.PromoValidateRes, err error) { + return logic.PromoValidate(ctx, req.Code, req.OrderType, req.TargetKey) +} + +func (c *cAdminPromo) PromoList(ctx context.Context, req *adminv1.PromoListReq) (res *adminv1.PromoListRes, err error) { + return logic.AdminPromoList(ctx, req) +} + +func (c *cAdminPromo) PromoSave(ctx context.Context, req *adminv1.PromoSaveReq) (res *adminv1.PromoSaveRes, err error) { + return logic.AdminPromoSave(ctx, req) +} + +func (c *cAdminPromo) PromoToggle(ctx context.Context, req *adminv1.PromoToggleReq) (res *adminv1.PromoToggleRes, err error) { + return logic.AdminPromoToggle(ctx, req.Id, req.Status) +} + +func (c *cAdminPromo) PromoDelete(ctx context.Context, req *adminv1.PromoDeleteReq) (res *adminv1.PromoDeleteRes, err error) { + return logic.AdminPromoDelete(ctx, req.Id) +} + +func (c *cAdminPromo) PromoUsages(ctx context.Context, req *adminv1.PromoUsagesReq) (res *adminv1.PromoUsagesRes, err error) { + return logic.AdminPromoUsages(ctx, req) +} diff --git a/internal/controller/quota.go b/internal/controller/quota.go index 6ed2a04..7a9ddad 100644 --- a/internal/controller/quota.go +++ b/internal/controller/quota.go @@ -30,5 +30,10 @@ func (c *cQuota) QuotaPacks(ctx context.Context, req *v1.QuotaPacksReq) (res *v1 } func (c *cQuota) QuotaOrderCreate(ctx context.Context, req *v1.QuotaOrderCreateReq) (res *v1.QuotaOrderCreateRes, err error) { - return logic.QuotaOrderCreate(ctx, req.PackKey, req.ToolKey, req.Code) + return logic.QuotaOrderCreate(ctx, req.PackKey, req.ToolKey) +} + +// QuotaOrderPay 对已建次数包待支付订单构建支付参数(与会员同构) +func (c *cQuota) QuotaOrderPay(ctx context.Context, req *v1.QuotaOrderPayReq) (res *v1.QuotaOrderPayRes, err error) { + return logic.QuotaOrderPay(ctx, req.OutTradeNo, req.Code) } diff --git a/internal/controller/user.go b/internal/controller/user.go index ecb8e47..4271c5d 100644 --- a/internal/controller/user.go +++ b/internal/controller/user.go @@ -42,6 +42,11 @@ func (c *cUserAuth) ProfileUpdate(ctx context.Context, req *v1.ProfileUpdateReq) return logic.ProfileUpdate(ctx, req.Nickname, req.AvatarUrl) } +// AvatarUpload 用户头像上传(multipart/form-data,字段名 file;N3) +func (c *cUserAuth) AvatarUpload(ctx context.Context, req *v1.AvatarUploadReq) (res *v1.AvatarUploadRes, err error) { + return logic.AvatarUploadFromRequest(ctx) +} + func (c *cUserAuth) Level(ctx context.Context, req *v1.LevelReq) (res *v1.LevelRes, err error) { return logic.UserLevel(ctx) } diff --git a/internal/logic/admin.go b/internal/logic/admin.go index 28ff764..5d1bb46 100644 --- a/internal/logic/admin.go +++ b/internal/logic/admin.go @@ -3,17 +3,87 @@ package logic import ( "context" "encoding/json" + "sort" + "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" "golang.org/x/crypto/bcrypt" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" + "tool-api/internal/model/entity" ) +// ===== 看板等级口径(唯一真实来源:EffectiveLevelKey / IsLevelExpired)===== +// +// 约定(见 level.go):users.level_expire_at 为 NULL 表示**永久有效**, +// 管理员手动分配的等级一律为 NULL;只有已过期才回落默认等级。 + +// isActiveMember 是否为「有效会员」:生效等级 ≠ 默认等级即视为会员。 +// 生效等级走 EffectiveLevelKey —— 永久(NULL)与未过期同为有效,已过期回落默认等级。 +func isActiveMember(u *entity.Users) bool { + return EffectiveLevelKey(u) != consts.DefaultLevelKey +} + +// countActiveMembers 统计有效会员数(与 C 端 EffectiveLevel 口径完全一致)。 +func countActiveMembers(users []*entity.Users) int64 { + var n int64 + for _, u := range users { + if isActiveMember(u) { + n++ + } + } + return n +} + +// levelDistribution 按「生效等级」聚合用户分布:已过期用户归入默认等级桶。 +func levelDistribution(users []*entity.Users) map[string]int64 { + dist := make(map[string]int64, 4) + for _, u := range users { + dist[EffectiveLevelKey(u)]++ + } + return dist +} + +// resolveSort 解析排序参数:sortBy 必须命中白名单(key=前端字段,value=真实列名), +// 未命中则回落到 fallback(形如 "id desc" / "sort asc")。返回 (column, direction)。 +// 通过白名单映射,杜绝把用户输入直接拼进 ORDER BY 造成的注入。 +func resolveSort(sortBy, sortOrder string, whitelist map[string]string, fallback string) (column, direction string) { + const ( + dirAsc = "asc" + dirDesc = "desc" + ) + col, ok := whitelist[sortBy] + if !ok || col == "" { + // 解析 fallback:"列名 方向" + parts := strings.Fields(fallback) + if len(parts) == 2 { + return parts[0], strings.ToLower(parts[1]) + } + if len(parts) == 1 { + return parts[0], dirDesc + } + return "id", dirDesc + } + if strings.EqualFold(sortOrder, dirAsc) { + return col, dirAsc + } + return col, dirDesc +} + +// applySort 把 resolveSort 的结果作用到查询上。 +func applySort(m *gdb.Model, sortBy, sortOrder string, whitelist map[string]string, fallback string) *gdb.Model { + col, dir := resolveSort(sortBy, sortOrder, whitelist, fallback) + if dir == "asc" { + return m.OrderAsc(col) + } + return m.OrderDesc(col) +} + // ===== 登录 ===== func AdminLogin(ctx context.Context, account, password string) (*v1.LoginRes, error) { @@ -49,12 +119,14 @@ func AdminMyInfo(ctx context.Context) (*v1.MyInfoRes, error) { if record.IsEmpty() { return nil, gerror.NewCode(gcodeUnauthorized(), "管理员不存在") } + roleValue := record["role_value"].String() return &v1.MyInfoRes{ - Id: record["id"].Int64(), - Account: record["account"].String(), - NickName: record["nick_name"].String(), - RoleName: record["role_name"].String(), - RoleValue: record["role_value"].String(), + Id: record["id"].Int64(), + Account: record["account"].String(), + NickName: record["nick_name"].String(), + RoleName: record["role_name"].String(), + RoleValue: roleValue, + Permissions: PermissionsOf(roleValue), }, nil } @@ -67,16 +139,38 @@ func AdminUserList(ctx context.Context, req *v1.UserListReq) (*v1.UserListRes, e if req.PageSize <= 0 { req.PageSize = 20 } - m := g.Model(consts.TableUsers) + // 软删除:列表默认排除已删用户 + m := g.Model(consts.TableUsers).WhereNull("deleted_at") if req.Keyword != "" { kw := "%" + req.Keyword + "%" m = m.Where("nickname LIKE ? OR openid LIKE ?", kw, kw) } + // ===== T16 多条件筛选 ===== + if req.LevelKey != "" { + m = m.Where("level_key", req.LevelKey) + } + if req.Status != nil { + m = m.Where("status", *req.Status) + } + if req.DateFrom != "" { + m = m.WhereGTE("created_at", req.DateFrom+" 00:00:00") + } + if req.DateTo != "" { + m = m.WhereLTE("created_at", req.DateTo+" 23:59:59") + } + // ===== T04.6 会员到期筛选 ===== + if cond := memberExpireCondition(req.MemberExpire); cond != "" { + m = m.Where(cond) + } total, err := m.Count() if err != nil { return nil, err } - records, err := m.Page(req.Page, req.PageSize).Order("id desc").All() + // ===== T16 排序(白名单,仅真实列)===== + var userSortWhitelist = map[string]string{ + "id": "id", "created_at": "created_at", "level_key": "level_key", "status": "status", + } + records, err := applySort(m.Page(req.Page, req.PageSize), req.SortBy, req.SortOrder, userSortWhitelist, "id desc").All() if err != nil { return nil, err } @@ -139,26 +233,108 @@ func levelNameMap(ctx context.Context) (map[string]string, error) { return m, nil } -func AdminUserSetLevel(ctx context.Context, userId int64, levelKey string) (*v1.UserSetLevelRes, error) { - level, err := g.Model(consts.TableLevels).Where("level_key", levelKey).One() +func AdminUserSetLevel(ctx context.Context, userId int64, levelKey, expireAt, remark string) (*v1.UserSetLevelRes, error) { + // 等级必须存在且已启用,否则 4012(弹窗内红字,不关闭弹窗) + level, err := g.Model(consts.TableLevels).Where("level_key", levelKey).Where("is_enabled", 1).One() if err != nil { return nil, err } if level.IsEmpty() { - return nil, gerror.New("等级不存在") + return nil, errLevelInvalid("等级不存在或已停用") } - if _, err = g.Model(consts.TableUsers).Where("id", userId). - Data(g.Map{"level_key": levelKey}).Update(); err != nil { + // 软删除:已删用户不可再操作等级 + before, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One() + if err != nil { return nil, err } + if before.IsEmpty() { + return nil, gerror.New("用户不存在") + } + + prevExpire := before["level_expire_at"].GTime() + prevPermanent := prevExpire == nil || prevExpire.IsZero() + newExpire := parseOptionalTime(expireAt) // 空 → nil → 写 NULL(永久) + // 约定 S10:level_expire_at IS NULL = 永久;续费/手动开通**不得把永久覆写成有限期**。 + // 判定抽为纯函数 levelExpireTransitionErr(见 admin_level_s10.go),便于单测覆盖。 + if err = levelExpireTransitionErr(prevPermanent, newExpire); err != nil { + return nil, err + } + + // newExpire 为 nil 时写 NULL(GoFrame 将 nil 识别为 NULL) + var expireValue interface{} + if newExpire != nil { + expireValue = newExpire + } + if _, err = g.Model(consts.TableUsers).Where("id", userId). + Data(g.Map{ + "level_key": levelKey, + "level_expire_at": expireValue, + "updated_at": gtime.Now(), + }).Update(); err != nil { + return nil, err + } + // T17 审计:改用户等级属敏感操作(remark 落审计备注) + WriteAudit(ctx, AuditEntry{ + Action: "user.set_level", + TargetType: "user", + TargetId: auditId(userId), + Before: g.Map{"level_key": before["level_key"].String(), "level_expire_at": timeStr(prevExpire)}, + After: g.Map{"level_key": levelKey, "level_expire_at": timeStr(newExpire)}, + Result: consts.AuditResultSuccess, + Remark: remark, + }) return &v1.UserSetLevelRes{}, nil } +// memberExpireCondition 把「会员到期」筛选项映射为 WHERE 条件(空/未知 → 不加条件)。 +// +// 口径与 EffectiveLevelKey 一致:level_expire_at IS NULL = 永久有效。 +// - expiring7 / expiring30:7/30 天内到期 +// - expired :已过期 +// - forever :永久有效(NULL) +// - none :未开通(仍是默认等级且无到期时间) +func memberExpireCondition(kind string) string { + now := time.Now() + ts := func(t time.Time) string { return t.Format("2006-01-02 15:04:05") } + switch kind { + case "expiring7": + return "level_expire_at IS NOT NULL AND level_expire_at > '" + ts(now) + + "' AND level_expire_at <= '" + ts(now.AddDate(0, 0, 7)) + "'" + case "expiring30": + return "level_expire_at IS NOT NULL AND level_expire_at > '" + ts(now) + + "' AND level_expire_at <= '" + ts(now.AddDate(0, 0, 30)) + "'" + case "expired": + return "level_expire_at IS NOT NULL AND level_expire_at <= '" + ts(now) + "'" + case "forever": + return "level_expire_at IS NULL" + case "none": + return "level_key = '" + consts.DefaultLevelKey + "' AND level_expire_at IS NULL" + default: + return "" + } +} + func AdminUserStatus(ctx context.Context, userId int64, status int) (*v1.UserStatusRes, error) { + // 软删除:已删用户不可再启停 + before, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One() + if err != nil { + return nil, err + } + if before.IsEmpty() { + return nil, gerror.New("用户不存在") + } if _, err := g.Model(consts.TableUsers).Where("id", userId). Data(g.Map{"status": status}).Update(); err != nil { return nil, err } + WriteAudit(ctx, AuditEntry{ + Action: "user.set_status", + TargetType: "user", + TargetId: auditId(userId), + Before: g.Map{"status": before["status"].Int()}, + After: g.Map{"status": status}, + Result: consts.AuditResultSuccess, + }) return &v1.UserStatusRes{}, nil } @@ -196,7 +372,19 @@ func AdminLevelSave(ctx context.Context, req *v1.LevelSaveReq) (*v1.LevelSaveRes "remark": req.Remark, } if req.Id > 0 { - // 编辑:level_key 只读,不修改 + // 编辑:level_key 是跨端契约字段,**不允许变更**。 + // 前端把该输入框置为 disabled 只是体验层,服务端才是真闸; + // 这里读回库中现值做校验(防止未来有人改坏 data map 或直接调接口)。 + exist, err := g.Model(consts.TableLevels).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("等级不存在") + } + if err = validateLevelKeyUnchanged(exist["level_key"].String(), req.LevelKey); err != nil { + return nil, err + } if _, err := g.Model(consts.TableLevels).Where("id", req.Id).Data(data).Update(); err != nil { return nil, err } @@ -216,6 +404,16 @@ func AdminLevelSave(ctx context.Context, req *v1.LevelSaveReq) (*v1.LevelSaveRes return &v1.LevelSaveRes{}, nil } +// validateLevelKeyUnchanged 校验「编辑等级时 level_key 未被变更」。 +// level_key 是跨端契约字段(小程序按它判定权益),服务端必须保证编辑不可变更它。 +// 抽成纯函数(不碰 DB)以便单测固化该闸门,见 admin_level_test.go。 +func validateLevelKeyUnchanged(existing, incoming string) error { + if existing != incoming { + return gerror.New("等级标识不可修改(跨端契约)") + } + return nil +} + // ===== 模块管理 ===== func AdminModuleList(ctx context.Context) (*v1.ModuleListRes, error) { @@ -223,7 +421,8 @@ func AdminModuleList(ctx context.Context) (*v1.ModuleListRes, error) { if err != nil { return nil, err } - counts, err := g.Model(consts.TableTools).Fields("module_key, COUNT(*) AS c").Group("module_key").All() + // 软删除:模块工具数只统计未删工具 + counts, err := g.Model(consts.TableTools).WhereNull("deleted_at").Fields("module_key, COUNT(*) AS c").Group("module_key").All() if err != nil { return nil, err } @@ -285,7 +484,8 @@ func AdminToolsList(ctx context.Context, req *v1.ToolsListReq) (*v1.ToolsListRes if req.PageSize <= 0 { req.PageSize = 50 } - m := g.Model(consts.TableTools) + // 软删除:列表默认排除已删工具 + m := g.Model(consts.TableTools).WhereNull("deleted_at") if req.ModuleKey != "" { m = m.Where("module_key", req.ModuleKey) } @@ -293,14 +493,25 @@ func AdminToolsList(ctx context.Context, req *v1.ToolsListReq) (*v1.ToolsListRes kw := "%" + req.Keyword + "%" m = m.Where("name LIKE ? OR tool_key LIKE ?", kw, kw) } - if !req.IncludeDisabled { + // ===== T16 多条件筛选(显式 is_enabled 优先于 includeDisabled 开关)===== + if req.IsEnabled != nil { + m = m.Where("is_enabled", *req.IsEnabled) + } else if !req.IncludeDisabled { m = m.Where("is_enabled", 1) } + if req.IsHot != nil { + m = m.Where("is_hot", *req.IsHot) + } total, err := m.Count() if err != nil { return nil, err } - records, err := m.Page(req.Page, req.PageSize).Order("sort asc").All() + // ===== T16 排序(白名单)===== + var toolSortWhitelist = map[string]string{ + "id": "id", "sort": "sort", "usage_count": "usage_count", "is_enabled": "is_enabled", + "is_hot": "is_hot", "name": "name", "module_key": "module_key", + } + records, err := applySort(m.Page(req.Page, req.PageSize), req.SortBy, req.SortOrder, toolSortWhitelist, "sort asc").All() if err != nil { return nil, err } @@ -362,7 +573,7 @@ func AdminToolsSave(ctx context.Context, req *v1.ToolsSaveReq) (*v1.ToolsSaveRes } if req.Id > 0 { // 编辑:tool_key 只读,不修改 - if _, err := g.Model(consts.TableTools).Where("id", req.Id).Data(data).Update(); err != nil { + if _, err := g.Model(consts.TableTools).Where("id", req.Id).WhereNull("deleted_at").Data(data).Update(); err != nil { return nil, err } } else { @@ -385,7 +596,7 @@ func AdminToolsToggle(ctx context.Context, id int64, field string, value int) (* if field != "is_enabled" && field != "is_hot" { return nil, gerror.New("不支持的字段") } - if _, err := g.Model(consts.TableTools).Where("id", id).Data(g.Map{field: value}).Update(); err != nil { + if _, err := g.Model(consts.TableTools).Where("id", id).WhereNull("deleted_at").Data(g.Map{field: value}).Update(); err != nil { return nil, err } return &v1.ToolsToggleRes{}, nil @@ -394,8 +605,14 @@ func AdminToolsToggle(ctx context.Context, id int64, field string, value int) (* // ===== 数据看板 ===== func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) { - res := &v1.DashboardStatsRes{WeekTrend: []v1.TrendItem{}, ToolsTop: []v1.TopItem{}} - if c, err := g.Model(consts.TableUsers).Count(); err == nil { + res := &v1.DashboardStatsRes{ + WeekTrend: []v1.TrendItem{}, + ToolsTop: []v1.TopItem{}, + RevenueTrend: []v1.AmountItem{}, + LevelDist: []v1.LevelDistItem{}, + OrderDist: []v1.DistItem{}, + } + if c, err := g.Model(consts.TableUsers).WhereNull("deleted_at").Count(); err == nil { res.UserCount = int64(c) } if c, err := g.Model(consts.TableUsageLogs).Count(); err == nil { @@ -403,8 +620,8 @@ func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) { } // 今日活跃:今天有使用记录或登录过的用户数 record, err := g.DB().GetOne(ctx, - "SELECT COUNT(*) AS c FROM users u WHERE DATE(u.last_login_at) = CURDATE() "+ - "OR EXISTS (SELECT 1 FROM tool_usage_logs l WHERE l.user_id = u.id AND DATE(l.used_at) = CURDATE())") + "SELECT COUNT(*) AS c FROM users u WHERE u.deleted_at IS NULL AND (DATE(u.last_login_at) = CURDATE() "+ + "OR EXISTS (SELECT 1 FROM tool_usage_logs l WHERE l.user_id = u.id AND DATE(l.used_at) = CURDATE()))") if err == nil && !record.IsEmpty() { res.TodayActive = record["c"].Int64() } @@ -432,6 +649,108 @@ func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) { res.ToolsTop = append(res.ToolsTop, v1.TopItem{ToolKey: key, Name: names[key], Count: r["c"].Int64()}) } } + + // ===== T16 指标扩充:会员 / 收入 / 额度 / 分布 ===== + + // 有效会员 + 等级分布:一次取出 (level_key, level_expire_at),在 Go 层用 + // EffectiveLevelKey 归一 —— 与 C 端 EffectiveLevel 口径**完全一致**(唯一真实来源)。 + // 关键修复:level_expire_at 为 NULL 表示永久有效,必须计入有效会员 + // (旧 SQL 用 `level_expire_at IS NOT NULL` 把所有永久会员漏掉了)。 + var memberUsers []*entity.Users + if rows, err := g.DB().GetAll(ctx, + "SELECT level_key, level_expire_at FROM users WHERE deleted_at IS NULL"); err == nil { + memberUsers = make([]*entity.Users, 0, len(rows)) + for _, r := range rows { + memberUsers = append(memberUsers, &entity.Users{ + LevelKey: r["level_key"].String(), + LevelExpireAt: r["level_expire_at"].GTime(), + }) + } + } + res.MemberCount = countActiveMembers(memberUsers) + + // 已发货订单数 + 累计收入(只统计已发货,待支付/退款不计入) + if r, err := g.DB().GetOne(ctx, + "SELECT COUNT(*) AS c, COALESCE(SUM(price_cents),0) AS amt FROM member_orders WHERE status = ?", + consts.OrderStatusDelivered); err == nil && !r.IsEmpty() { + res.OrderCount = r["c"].Int64() + res.RevenueCents = r["amt"].Int64() + } + + // 近 7 日收入趋势(按发货时间聚合,缺失日期补 0) + revTrend, err := g.DB().GetAll(ctx, + "SELECT DATE(delivered_at) AS d, COALESCE(SUM(price_cents),0) AS amt FROM member_orders "+ + "WHERE status = ? AND delivered_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY d", + consts.OrderStatusDelivered) + if err == nil { + amtByDate := map[string]int64{} + for _, r := range revTrend { + amtByDate[r["d"].String()] = r["amt"].Int64() + } + for i := 6; i >= 0; i-- { + day := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + res.RevenueTrend = append(res.RevenueTrend, v1.AmountItem{Date: day, Cents: amtByDate[day]}) + } + } + + // 额度使用:本周期免费 / 会员已用合计 + if r, err := g.DB().GetOne(ctx, + "SELECT COALESCE(SUM(free_used),0) AS f, COALESCE(SUM(member_used),0) AS m FROM user_quota_usage"); err == nil && !r.IsEmpty() { + res.QuotaFreeUsed = r["f"].Int64() + res.QuotaMemberUsed = r["m"].Int64() + } + // 付费额度累计消耗 = Σ(累计购买 - 当前剩余) + if v, err := g.DB().GetValue(ctx, + "SELECT COALESCE(SUM(total_bought - times_left),0) FROM user_tool_quota"); err == nil { + res.QuotaPaidUsed = v.Int64() + } + + // 等级分布:按「生效等级」聚合(已过期用户归入默认等级桶),复用上面的 memberUsers。 + // 展示名:优先取等级名;默认等级无名时显示「普通用户」;其余回落 key;空 key 归「未知」。 + { + names, _ := levelNameMap(ctx) + dist := levelDistribution(memberUsers) + keys := make([]string, 0, len(dist)) + for k := range dist { + keys = append(keys, k) + } + // 稳定性:数量降序,数量相同按 key 升序(图表展示直观且结果确定) + sort.Slice(keys, func(i, j int) bool { + if dist[keys[i]] != dist[keys[j]] { + return dist[keys[i]] > dist[keys[j]] + } + return keys[i] < keys[j] + }) + for _, key := range keys { + name := names[key] + if name == "" { + switch { + case key == consts.DefaultLevelKey: + name = "普通用户" + case key != "": + name = key + default: + name = "未知" + } + } + res.LevelDist = append(res.LevelDist, v1.LevelDistItem{LevelKey: key, Name: name, Count: dist[key]}) + } + } + + // 订单构成(会员套餐 / 次数包,仅已发货) + orderCounts, err := g.DB().GetAll(ctx, + "SELECT order_type, COUNT(*) AS c FROM member_orders WHERE status = ? GROUP BY order_type", + consts.OrderStatusDelivered) + if err == nil { + byType := map[int]int64{} + for _, r := range orderCounts { + byType[r["order_type"].Int()] = r["c"].Int64() + } + res.OrderDist = append(res.OrderDist, + v1.DistItem{Key: "member", Label: "会员套餐", Value: byType[consts.OrderTypeMember]}, + v1.DistItem{Key: "quota", Label: "次数包", Value: byType[consts.OrderTypeQuota]}, + ) + } return res, nil } diff --git a/internal/logic/admin_account.go b/internal/logic/admin_account.go new file mode 100644 index 0000000..5a7ef66 --- /dev/null +++ b/internal/logic/admin_account.go @@ -0,0 +1,193 @@ +package logic + +import ( + "context" + "strconv" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "golang.org/x/crypto/bcrypt" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 管理员账号管理(T17) +// ============================================================================ + +// AdminList 管理员列表 +func AdminList(ctx context.Context) (*v1.AdminListRes, error) { + records, err := g.Model(consts.TableAdminUsers).OrderAsc("id").All() + if err != nil { + return nil, err + } + list := make([]v1.AdminItem, 0, len(records)) + for _, r := range records { + list = append(list, v1.AdminItem{ + Id: r["id"].Int64(), + Account: r["account"].String(), + NickName: r["nick_name"].String(), + RoleName: r["role_name"].String(), + RoleValue: r["role_value"].String(), + Status: r["status"].Int(), + CreatedAt: r["created_at"].String(), + }) + } + return &v1.AdminListRes{List: list}, nil +} + +// lastSuperGuard 保护「最后一个启用中的超级管理员」: +// 若该管理员当前是启用中的超管,而改动后会失去「启用中的超管」身份(降角色或停用), +// 且库中仅剩这一个,则拒绝——避免把自己锁在门外。 +func lastSuperGuard(ctx context.Context, id int64, newRole string, newStatus int) error { + rec, err := g.Model(consts.TableAdminUsers).Where("id", id).One() + if err != nil { + return err + } + if rec.IsEmpty() { + return nil + } + isEnabledSuper := rec["role_value"].String() == consts.AdminRoleSuper && + rec["status"].Int() == consts.AdminStatusEnabled + if !isEnabledSuper { + return nil + } + stillSuper := newRole == consts.AdminRoleSuper && newStatus == consts.AdminStatusEnabled + if stillSuper { + return nil + } + count, err := g.Model(consts.TableAdminUsers). + Where("role_value", consts.AdminRoleSuper). + Where("status", consts.AdminStatusEnabled). + Count() + if err != nil { + return err + } + if count <= 1 { + return gerror.New("至少保留一个启用中的超级管理员") + } + return nil +} + +// AdminSave 新增/修改管理员。编辑不清空密码(除非显式传入新密码)。 +func AdminSave(ctx context.Context, req *v1.AdminSaveReq) (*v1.AdminSaveRes, error) { + if req.Id > 0 { + rec, err := g.Model(consts.TableAdminUsers).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if rec.IsEmpty() { + return nil, gerror.New("管理员不存在") + } + if err = lastSuperGuard(ctx, req.Id, req.RoleValue, rec["status"].Int()); err != nil { + return nil, err + } + data := g.Map{ + "nick_name": req.NickName, + "role_name": roleNameOf(req.RoleValue), + "role_value": req.RoleValue, + } + pwdChanged := strings.TrimSpace(req.Password) != "" + if pwdChanged { + hash, herr := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if herr != nil { + return nil, herr + } + data["password"] = string(hash) + } + if _, err = g.Model(consts.TableAdminUsers).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "admin.update", + TargetType: "admin", + TargetId: strconv.FormatInt(req.Id, 10), + Before: g.Map{ + "nick_name": rec["nick_name"].String(), + "role_value": rec["role_value"].String(), + }, + After: g.Map{ + "nick_name": req.NickName, + "role_value": req.RoleValue, + "pwd_changed": pwdChanged, + }, + Result: consts.AuditResultSuccess, + }) + return &v1.AdminSaveRes{}, nil + } + + account := strings.TrimSpace(req.Account) + if account == "" { + return nil, gerror.New("请填写登录账号") + } + if strings.TrimSpace(req.Password) == "" { + return nil, gerror.New("请填写登录密码") + } + count, err := g.Model(consts.TableAdminUsers).Where("account", account).Count() + if err != nil { + return nil, err + } + if count > 0 { + return nil, gerror.New("账号已存在") + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + newId, err := g.Model(consts.TableAdminUsers).Data(g.Map{ + "account": account, + "password": string(hash), + "nick_name": req.NickName, + "role_name": roleNameOf(req.RoleValue), + "role_value": req.RoleValue, + "status": consts.AdminStatusEnabled, + "created_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "admin.create", + TargetType: "admin", + TargetId: strconv.FormatInt(newId, 10), + After: g.Map{ + "account": account, + "nick_name": req.NickName, + "role_value": req.RoleValue, + }, + Result: consts.AuditResultSuccess, + }) + return &v1.AdminSaveRes{}, nil +} + +// AdminStatus 启停管理员 +func AdminStatus(ctx context.Context, id int64, status int) (*v1.AdminStatusRes, error) { + if status != consts.AdminStatusEnabled && status != consts.AdminStatusDisabled { + return nil, gerror.New("状态非法") + } + rec, err := g.Model(consts.TableAdminUsers).Where("id", id).One() + if err != nil { + return nil, err + } + if rec.IsEmpty() { + return nil, gerror.New("管理员不存在") + } + if err = lastSuperGuard(ctx, id, rec["role_value"].String(), status); err != nil { + return nil, err + } + if _, err = g.Model(consts.TableAdminUsers).Where("id", id).Data(g.Map{"status": status}).Update(); err != nil { + return nil, err + } + WriteAudit(ctx, AuditEntry{ + Action: "admin.status", + TargetType: "admin", + TargetId: strconv.FormatInt(id, 10), + Before: g.Map{"status": rec["status"].Int()}, + After: g.Map{"status": status}, + Result: consts.AuditResultSuccess, + }) + return &v1.AdminStatusRes{}, nil +} diff --git a/internal/logic/admin_audit.go b/internal/logic/admin_audit.go new file mode 100644 index 0000000..4a5596b --- /dev/null +++ b/internal/logic/admin_audit.go @@ -0,0 +1,142 @@ +package logic + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// auditId 审计对象主键统一转字符串(避免与 note.go 的 itoa(int) 冲突) +func auditId(v int64) string { return strconv.FormatInt(v, 10) } + +// ============================================================================ +// 后台操作审计(T17) +// +// WriteAudit 只负责「落一条记录」,调用点负责提供前后值;写入失败仅告警, +// 绝不因审计异常影响主流程(审计是旁路,不是业务前置条件)。 +// ============================================================================ + +// AuditEntry 一条待写入的操作审计 +type AuditEntry struct { + Action string // 动作,如 user.set_level / event.status / admin.create + TargetType string // 对象类型:user/event/table/admin/export... + TargetId string // 对象主键(字符串,兼容复合键) + Before interface{} // 操作前值(可为 nil / map / struct) + After interface{} // 操作后值 + Result int // consts.AuditResultSuccess / Fail + Remark string +} + +// auditJSON 把前后值序列化成 JSON 字符串;nil 存 NULL。 +func auditJSON(v interface{}) interface{} { + if v == nil { + return nil + } + b, err := json.Marshal(v) + if err != nil { + return nil + } + return string(b) +} + +func auditClientIp(ctx context.Context) string { + req := g.RequestFromCtx(ctx) + if req == nil { + return "" + } + return req.GetClientIp() +} + +// WriteAudit 写入一条操作审计(操作人取自上下文,由 AdminPerm 中间件注入)。 +func WriteAudit(ctx context.Context, e AuditEntry) { + data := g.Map{ + "admin_id": CtxAdminId(ctx), + "admin_account": CtxAdminAccount(ctx), + "admin_name": CtxAdminName(ctx), + "role_value": CtxAdminRole(ctx), + "action": e.Action, + "target_type": e.TargetType, + "target_id": e.TargetId, + "before_json": auditJSON(e.Before), + "after_json": auditJSON(e.After), + "result": e.Result, + "remark": e.Remark, + "ip": auditClientIp(ctx), + "created_at": gtime.Now(), + } + if _, err := g.Model(consts.TableAdminAuditLog).Data(data).Insert(); err != nil { + g.Log().Warningf(ctx, "[audit] 写入失败 action=%s err=%v", e.Action, err) + } +} + +// AdminAuditList 审计列表(多条件筛选 + 分页)。 +func AdminAuditList(ctx context.Context, req *v1.AuditListReq) (*v1.AuditListRes, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + m := g.Model(consts.TableAdminAuditLog) + if req.AdminKeyword != "" { + m = m.WhereLike("admin_account", "%"+req.AdminKeyword+"%") + } + if req.Action != "" { + m = m.WhereLike("action", "%"+req.Action+"%") + } + if req.Result != nil { + m = m.Where("result", *req.Result) + } + if req.DateFrom != "" { + m = m.WhereGTE("created_at", req.DateFrom+" 00:00:00") + } + if req.DateTo != "" { + m = m.WhereLTE("created_at", req.DateTo+" 23:59:59") + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(req.Page, req.PageSize).OrderDesc("id").All() + if err != nil { + return nil, err + } + list := make([]v1.AuditItem, 0, len(records)) + for _, r := range records { + list = append(list, v1.AuditItem{ + Id: r["id"].Int64(), + AdminId: r["admin_id"].Int64(), + AdminAccount: r["admin_account"].String(), + AdminName: r["admin_name"].String(), + RoleValue: r["role_value"].String(), + Action: r["action"].String(), + TargetType: r["target_type"].String(), + TargetId: r["target_id"].String(), + BeforeJson: r["before_json"].String(), + AfterJson: r["after_json"].String(), + Result: r["result"].Int(), + Remark: r["remark"].String(), + Ip: r["ip"].String(), + CreatedAt: r["created_at"].String(), + }) + } + return &v1.AuditListRes{List: list, Total: total}, nil +} + +// AdminAuditRecord 前端主动上报一条审计(如「导出」为纯前端动作,服务端无法感知)。 +func AdminAuditRecord(ctx context.Context, req *v1.AuditRecordReq) (*v1.AuditRecordRes, error) { + WriteAudit(ctx, AuditEntry{ + Action: req.Action, + TargetType: req.TargetType, + TargetId: req.TargetId, + Result: consts.AuditResultSuccess, + Remark: req.Remark, + }) + return &v1.AuditRecordRes{}, nil +} diff --git a/internal/logic/admin_batch.go b/internal/logic/admin_batch.go new file mode 100644 index 0000000..1691fd3 --- /dev/null +++ b/internal/logic/admin_batch.go @@ -0,0 +1,245 @@ +package logic + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 批量操作(T15 批量改值 + T18 软删除) +// +// 设计要点: +// - 幂等:同一批重复提交结果一致;把值改为当前值仍算成功;对**已软删**的 id 再次 +// 执行 delete 也计成功(不塞进 failures); +// - 部分成功:不存在的 id 计入 failed 并附原因,不影响其余 id 的更新; +// - 高效:存在性一次 IN 查询、更新一次 IN 批量,避免 N 次单条请求。 +// +// action 白名单:user = status / level / delete;tool = is_enabled / is_hot / delete; +// employee = status / delete。 +// ============================================================================ + +// dedupBatchIds 去重并剔除非法 id(<=0),保持首次出现顺序。 +func dedupBatchIds(in []int64) []int64 { + seen := make(map[int64]bool, len(in)) + out := make([]int64, 0, len(in)) + for _, id := range in { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return out +} + +// partitionBatchIds 按「是否存在」把 id 分为可操作(ok)与缺失(missing),保持入参顺序。 +func partitionBatchIds(requested []int64, existing map[int64]bool) (ok []int64, missing []int64) { + ok = make([]int64, 0, len(requested)) + missing = make([]int64, 0) + for _, id := range requested { + if existing[id] { + ok = append(ok, id) + continue + } + missing = append(missing, id) + } + return ok, missing +} + +// batchFailures 把缺失 id 转为失败明细。 +func batchFailures(missing []int64, reason string) []v1.BatchFailure { + out := make([]v1.BatchFailure, 0, len(missing)) + for _, id := range missing { + out = append(out, v1.BatchFailure{Id: id, Reason: reason}) + } + return out +} + +// existingIdSet 一次 IN 查询出「实际存在的 id 集合」。 +// +// 🔴 必须 `.Unscoped()`:GoFrame v2.10 会给所有 Model 查询(select/count/update/delete) +// **自动**追加 `deleted_at IS NULL`(见 softdelete.go 头部不变量)。若不加 Unscoped, +// 已软删的行会被自动过滤掉 → 落进 missing → 被判为「不存在」→ 返回 failed, +// 直接违反「重复删除已删 id 计成功」的幂等契约(且代码注释与实现相反,极隐蔽)。 +// Unscoped 让软删行照常出现在结果里、计入 ok —— delete 幂等正是依赖此语义。 +func existingIdSet(ctx context.Context, table string, ids []int64) (map[int64]bool, error) { + set := make(map[int64]bool, len(ids)) + if len(ids) == 0 { + return set, nil + } + records, err := g.Model(table).Unscoped().Fields("id").WhereIn("id", ids).All() + if err != nil { + return nil, err + } + for _, r := range records { + set[r["id"].Int64()] = true + } + return set, nil +} + +// normalizeToggle 归一化为 0/1。 +func normalizeToggle(v int) int { + if v == 1 { + return 1 + } + return 0 +} + +// AdminUserBatch 批量操作用户:status(启停)/ level(改等级)/ delete(软删除,可恢复)。 +func AdminUserBatch(ctx context.Context, req *v1.UserBatchReq) (*v1.UserBatchRes, error) { + ids := dedupBatchIds(req.Ids) + if len(ids) == 0 { + return &v1.UserBatchRes{Failures: []v1.BatchFailure{}}, nil + } + existing, err := existingIdSet(ctx, consts.TableUsers, ids) + if err != nil { + return nil, err + } + ok, missing := partitionBatchIds(ids, existing) + + switch req.Action { + case "delete": + // 软删除:墓碑化 openid/username,保证可重新注册;已删 id 幂等计成功。 + if err = softDeleteUsers(ctx, ok); err != nil { + return nil, err + } + case "status", "level": + data := g.Map{} + if req.Action == "status" { + data["status"] = normalizeToggle(req.Status) + } else { + n, err := g.Model(consts.TableLevels).Where("level_key", req.LevelKey).Count() + if err != nil { + return nil, err + } + if n == 0 { + return nil, gerror.New("等级不存在") // 参数错误 → 整体失败,不做「部分成功」 + } + data["level_key"] = req.LevelKey + } + if len(ok) > 0 { + if _, err = g.Model(consts.TableUsers).WhereIn("id", ok).Data(data).Update(); err != nil { + return nil, err + } + } + default: + return nil, gerror.New("不支持的批量动作") + } + + res := &v1.UserBatchRes{ + Success: len(ok), + Failed: len(missing), + Failures: batchFailures(missing, "用户不存在"), + } + WriteAudit(ctx, AuditEntry{ + Action: "user.batch_" + req.Action, + TargetType: "user", + TargetId: "batch", + After: g.Map{ + "action": req.Action, "ids": ok, "status": req.Status, "level_key": req.LevelKey, + "success": res.Success, "failed": res.Failed, + }, + Result: consts.AuditResultSuccess, + }) + return res, nil +} + +// AdminToolsBatch 批量操作工具:is_enabled / is_hot(改值)/ delete(软删除,可恢复)。 +func AdminToolsBatch(ctx context.Context, req *v1.ToolsBatchReq) (*v1.ToolsBatchRes, error) { + ids := dedupBatchIds(req.Ids) + if len(ids) == 0 { + return &v1.ToolsBatchRes{Failures: []v1.BatchFailure{}}, nil + } + existing, err := existingIdSet(ctx, consts.TableTools, ids) + if err != nil { + return nil, err + } + ok, missing := partitionBatchIds(ids, existing) + + switch req.Action { + case "delete": + // 软删除:仅置 deleted_at(不墓碑化 tool_key,见 softdelete.go 说明)。 + if err = softDeleteSimple(ctx, consts.TableTools, ok); err != nil { + return nil, err + } + case "is_enabled", "is_hot": + if len(ok) > 0 { + if _, err = g.Model(consts.TableTools).WhereIn("id", ok). + Data(g.Map{req.Action: normalizeToggle(req.Value)}).Update(); err != nil { + return nil, err + } + } + default: + return nil, gerror.New("不支持的批量动作") + } + + res := &v1.ToolsBatchRes{ + Success: len(ok), + Failed: len(missing), + Failures: batchFailures(missing, "工具不存在"), + } + WriteAudit(ctx, AuditEntry{ + Action: "tool.batch_" + req.Action, + TargetType: "tool", + TargetId: "batch", + After: g.Map{ + "action": req.Action, "value": normalizeToggle(req.Value), "ids": ok, + "success": res.Success, "failed": res.Failed, + }, + Result: consts.AuditResultSuccess, + }) + return res, nil +} + +// AdminEmployeeBatch 批量操作员工:status(启停)/ delete(软删除,可恢复)。 +func AdminEmployeeBatch(ctx context.Context, req *v1.EmployeeBatchReq) (*v1.EmployeeBatchRes, error) { + ids := dedupBatchIds(req.Ids) + if len(ids) == 0 { + return &v1.EmployeeBatchRes{Failures: []v1.BatchFailure{}}, nil + } + existing, err := existingIdSet(ctx, consts.TableEmployees, ids) + if err != nil { + return nil, err + } + ok, missing := partitionBatchIds(ids, existing) + + switch req.Action { + case "delete": + // 软删除:墓碑化 phone,保证同企业同手机号可重新录入。 + if err = softDeleteEmployees(ctx, ok); err != nil { + return nil, err + } + case "status": + if len(ok) > 0 { + if _, err = g.Model(consts.TableEmployees).WhereIn("id", ok). + Data(g.Map{"status": normalizeToggle(req.Status)}).Update(); err != nil { + return nil, err + } + } + default: + return nil, gerror.New("不支持的批量动作") + } + + res := &v1.EmployeeBatchRes{ + Success: len(ok), + Failed: len(missing), + Failures: batchFailures(missing, "员工不存在"), + } + // T17/T18:员工批量也纳入审计(与 user/tool 批量保持一致) + WriteAudit(ctx, AuditEntry{ + Action: "employee.batch_" + req.Action, + TargetType: "employee", + TargetId: "batch", + After: g.Map{ + "action": req.Action, "ids": ok, "status": req.Status, + "success": res.Success, "failed": res.Failed, + }, + Result: consts.AuditResultSuccess, + }) + return res, nil +} diff --git a/internal/logic/admin_batch_test.go b/internal/logic/admin_batch_test.go new file mode 100644 index 0000000..ebd5aac --- /dev/null +++ b/internal/logic/admin_batch_test.go @@ -0,0 +1,58 @@ +package logic + +import "testing" + +// TestDedupBatchIds 去重 + 剔除非法 id,并保持首次出现顺序。 +func TestDedupBatchIds(t *testing.T) { + got := dedupBatchIds([]int64{3, 1, 3, 0, -2, 1, 5}) + want := []int64{3, 1, 5} + if len(got) != len(want) { + t.Fatalf("len=%d want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d]=%d want %d (%v)", i, got[i], want[i], got) + } + } + if n := len(dedupBatchIds(nil)); n != 0 { + t.Fatalf("nil 应返回空,got len=%d", n) + } +} + +// TestPartitionBatchIds 按存在性拆分,且保持入参顺序。 +func TestPartitionBatchIds(t *testing.T) { + ok, missing := partitionBatchIds([]int64{5, 2, 7, 3}, map[int64]bool{5: true, 7: true}) + if len(ok) != 2 || ok[0] != 5 || ok[1] != 7 { + t.Fatalf("ok=%v want [5 7]", ok) + } + if len(missing) != 2 || missing[0] != 2 || missing[1] != 3 { + t.Fatalf("missing=%v want [2 3]", missing) + } +} + +// TestBatchFailures 失败明细映射(id + 原因)。 +func TestBatchFailures(t *testing.T) { + out := batchFailures([]int64{9, 8}, "用户不存在") + if len(out) != 2 { + t.Fatalf("len=%d want 2", len(out)) + } + if out[0].Id != 9 || out[0].Reason != "用户不存在" { + t.Fatalf("out[0]=%+v", out[0]) + } + if out[1].Id != 8 { + t.Fatalf("out[1].Id=%d want 8", out[1].Id) + } + if n := len(batchFailures(nil, "x")); n != 0 { + t.Fatalf("nil 应返回空,got len=%d", n) + } +} + +// TestNormalizeToggle 归一化为 0/1(幂等写值)。 +func TestNormalizeToggle(t *testing.T) { + cases := map[int]int{0: 0, 1: 1, 2: 0, -3: 0, 99: 0} + for in, want := range cases { + if got := normalizeToggle(in); got != want { + t.Fatalf("normalizeToggle(%d)=%d want %d", in, got, want) + } + } +} diff --git a/internal/logic/admin_level_s10.go b/internal/logic/admin_level_s10.go new file mode 100644 index 0000000..f16c6be --- /dev/null +++ b/internal/logic/admin_level_s10.go @@ -0,0 +1,26 @@ +package logic + +import ( + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +// ============================================================================ +// 约定 S10 · 等级到期时间变更的合法性(纯函数,便于单测) +// +// level_expire_at IS NULL/零值 = 永久有效;续费 / 手动开通**不得把永久覆写成有限期**。 +// 本函数由 AdminUserSetLevel 调用(抽离自原内联判定,行为不变)。 +// ============================================================================ + +// levelExpireTransitionErr 判定一次等级到期时间变更是否合法(约定 S10)。 +// +// - prevPermanent:变更前是否永久有效(level_expire_at 为 NULL/零值); +// - newExpire:变更后的到期时间(nil = 写 NULL = 永久)。 +// +// 非法情形:变更前永久、变更后有限期 → 拒绝;其余放行。 +func levelExpireTransitionErr(prevPermanent bool, newExpire *gtime.Time) error { + if prevPermanent && newExpire != nil { + return gerror.New("该用户当前为永久有效,不能改为有限期") + } + return nil +} diff --git a/internal/logic/admin_level_s10_test.go b/internal/logic/admin_level_s10_test.go new file mode 100644 index 0000000..268ba47 --- /dev/null +++ b/internal/logic/admin_level_s10_test.go @@ -0,0 +1,38 @@ +package logic + +import ( + "testing" + + "github.com/gogf/gf/v2/os/gtime" +) + +// ============================================================================ +// 约定 S10 单测(纯函数,不依赖 DB) +// +// level_expire_at IS NULL/零值 = 永久有效;续费 / 手动开通**不得把永久覆写成有限期**。 +// ============================================================================ + +func TestLevelExpireTransitionErr(t *testing.T) { + future := gtime.New("2999-01-01 00:00:00") + + cases := []struct { + name string + prevPermanent bool + newExpire *gtime.Time + wantErr bool + }{ + {"永久 → 永久(NULL) 放行", true, nil, false}, + {"永久 → 有限期 拒绝(S10)", true, future, true}, + {"有限期 → 有限期 放行", false, future, false}, + {"有限期 → 永久(NULL) 放行", false, nil, false}, + } + for _, c := range cases { + err := levelExpireTransitionErr(c.prevPermanent, c.newExpire) + if c.wantErr && err == nil { + t.Errorf("%s: 期望报错,实得 nil", c.name) + } + if !c.wantErr && err != nil { + t.Errorf("%s: 期望放行,实得 err=%v", c.name, err) + } + } +} diff --git a/internal/logic/admin_level_test.go b/internal/logic/admin_level_test.go new file mode 100644 index 0000000..524e084 --- /dev/null +++ b/internal/logic/admin_level_test.go @@ -0,0 +1,78 @@ +package logic + +import ( + "testing" + "time" + + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// TestCountActiveMembers_PermanentCounted 看板「有效会员」必须计入永久会员 +// (level_expire_at = NULL,管理员手动分配)。这是本次修复(#24)的核心回归点: +// 旧 SQL 用 `level_expire_at IS NOT NULL` 把永久会员全部漏掉了。 +func TestCountActiveMembers_PermanentCounted(t *testing.T) { + future := gtime.New(time.Now().Add(24 * time.Hour)) + past := gtime.New(time.Now().Add(-24 * time.Hour)) + users := []*entity.Users{ + {LevelKey: "v2", LevelExpireAt: nil}, // ① 永久(NULL)→ 应计入 + {LevelKey: "v2", LevelExpireAt: future}, // ② 未过期 → 应计入 + {LevelKey: "v2", LevelExpireAt: past}, // ③ 已过期 → 回落默认等级,不计入 + } + if got := countActiveMembers(users); got != 2 { + t.Fatalf("countActiveMembers=%d want 2", got) + } +} + +// TestLevelDistribution_EffectiveKey 等级分布按「生效等级」分组: +// 已过期用户必须落入默认等级桶,而不是其原始 level_key。 +func TestLevelDistribution_EffectiveKey(t *testing.T) { + future := gtime.New(time.Now().Add(24 * time.Hour)) + past := gtime.New(time.Now().Add(-24 * time.Hour)) + users := []*entity.Users{ + {LevelKey: "v2", LevelExpireAt: nil}, + {LevelKey: "v2", LevelExpireAt: future}, + {LevelKey: "v2", LevelExpireAt: past}, + } + dist := levelDistribution(users) + if dist["v2"] != 2 { + t.Fatalf("v2 桶=%d want 2 (%v)", dist["v2"], dist) + } + if dist[consts.DefaultLevelKey] != 1 { + t.Fatalf("默认等级桶=%d want 1 (%v)", dist[consts.DefaultLevelKey], dist) + } +} + +// TestCountActiveMembers_DefaultExcluded 默认等级(含空 key)不算有效会员。 +func TestCountActiveMembers_DefaultExcluded(t *testing.T) { + users := []*entity.Users{ + {LevelKey: consts.DefaultLevelKey, LevelExpireAt: nil}, + {LevelKey: "", LevelExpireAt: nil}, + } + if got := countActiveMembers(users); got != 0 { + t.Fatalf("countActiveMembers=%d want 0", got) + } + if n := countActiveMembers(nil); n != 0 { + t.Fatalf("nil 应返回 0,got %d", n) + } +} + +// TestValidateLevelKeyUnchanged 编辑等级时 level_key 是跨端契约字段,不允许变更(T01.6)。 +// 服务端闸门:前端把输入框 disabled 只是体验层,这里固化「变更即报错」的行为, +// 取消失败回归(例如未来有人误把 level_key 写进编辑 data map)。 +func TestValidateLevelKeyUnchanged(t *testing.T) { + // 未变更 → 放行 + if err := validateLevelKeyUnchanged("v2", "v2"); err != nil { + t.Fatalf("相同 level_key 应放行,got err=%v", err) + } + // 变更 → 报错 + if err := validateLevelKeyUnchanged("v2", "v3"); err == nil { + t.Fatalf("变更 level_key 应报错,got nil") + } + // 清空(旧展开式回填的典型故障)→ 报错 + if err := validateLevelKeyUnchanged("v2", ""); err == nil { + t.Fatalf("清空 level_key 应报错,got nil") + } +} diff --git a/internal/logic/admin_order.go b/internal/logic/admin_order.go new file mode 100644 index 0000000..65d85f8 --- /dev/null +++ b/internal/logic/admin_order.go @@ -0,0 +1,385 @@ +package logic + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 管理端「订单管理 + 退款」逻辑 +// +// 数据源:member_orders(order_type 1=会员套餐 2=次数包,同一张表)。 +// +// 退款语义(本次核心): +// - 仅允许 status=1(已发货) → 2(已退款);其它状态给可读业务错误(4015)。 +// - 幂等:status=2 再调 → 成功返回,但**不重复回收权益**(幂等由行锁 + status 判定保证)。 +// - revokeService=true → 回收所购服务: +// 会员套餐:回退 users.level_expire_at(减去本单购买的天数),回收后若已到期则同时回落默认等级; +// level_expire_at IS NULL(永久)**同样真取消**:置为当前时刻(已过期,用户裁定 2026-09-23), +// level_key 保留不清空,失效判定交给 EffectiveLevelKey。 +// 次数包: 扣回 user_tool_quota 的付费次数(扣到 0 为止,不出负数)。 +// revokeService=false → 仅改状态,权益保留。 +// - 释放券:退款时释放该订单占用的免单券 / 优惠码(仅当状态确实 1→2 时执行)。 +// - 审计:记录操作人(审计上下文)、订单号、是否回收服务。 +// +// 幂等/一致性设计说明: +// 回收权益与状态变更在**同一事务**内完成(按 out_trade_no 行锁),任一失败整体回滚 → +// 「已退款必然已按 revokeService 处理完毕」。新增列 service_revoked 记录**实际回收结果** +// (勾选「取消服务」且确有生效中的授权被解除才为 1;勾了但无可回收授权为 0,避免「列表显示已回收 +// 但实际什么都没回收」的不诚实记录),用于列表展示与审计留痕(即便将来放开「退款后再回收」也据此判重)。 +// ============================================================================ + +// AdminOrderList 订单列表(分页 + 关键字 + 状态筛选,默认创建时间倒序)。 +func AdminOrderList(ctx context.Context, req *v1.OrderListReq) (*v1.OrderListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableMemberOrders) + if req.Status != nil { + m = m.Where("status", *req.Status) + } + if kw := strings.TrimSpace(req.Keyword); kw != "" { + like := "%" + kw + "%" + // 关键字:订单号 直接匹配;用户名/昵称 通过 users 子查询匹配归属用户 + m = m.Where( + "out_trade_no LIKE ? OR user_id IN (SELECT id FROM "+consts.TableUsers+" WHERE nickname LIKE ? OR username LIKE ?)", + like, like, like, + ) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).OrderDesc("created_at").OrderDesc("id").All() + if err != nil { + return nil, err + } + + userInfo := orderUsersMap(ctx, records) + planNames, _ := planNameMap(ctx) + packNames, _ := quotaPackNameMap(ctx) + toolNames, _ := toolNameMap(ctx) + + list := make([]v1.OrderItem, 0, len(records)) + for _, r := range records { + uid := r["user_id"].Int64() + orderType := r["order_type"].Int() + if orderType == 0 { + orderType = consts.OrderTypeMember // 兼容加列之前的历史订单 + } + item := v1.OrderItem{ + Id: r["id"].Int64(), + OutTradeNo: r["out_trade_no"].String(), + UserId: uid, + OrderType: orderType, + PlanKey: r["plan_key"].String(), + PackKey: r["pack_key"].String(), + ToolKey: r["tool_key"].String(), + Times: r["times"].Int(), + OriginPriceCents: r["origin_price_cents"].Int64(), + DiscountCents: r["discount_cents"].Int64(), + PaidPriceCents: r["paid_price_cents"].Int64(), + PromoKind: r["promo_kind"].Int(), + Status: r["status"].Int(), + StatusText: orderStatusText(r["status"].Int()), + ServiceRevoked: r["service_revoked"].Int(), + CreatedAt: timeStr(r["created_at"].GTime()), + PaidAt: timeStr(r["paid_at"].GTime()), + RefundedAt: timeStr(r["refunded_at"].GTime()), + } + if u, ok := userInfo[uid]; ok { + item.Nickname = u["nickname"].String() + item.Username = u["username"].String() + item.AvatarUrl = u["avatar_url"].String() + } + item.Title = orderTitle(orderType, r["plan_key"].String(), r["pack_key"].String(), r["tool_key"].String(), + planNames, packNames, toolNames) + list = append(list, item) + } + return &v1.OrderListRes{List: list, Total: total}, nil +} + +// orderUsersMap 批量取订单归属用户信息(一次查询,避免 N+1)。 +func orderUsersMap(ctx context.Context, records gdb.Result) map[int64]gdb.Record { + out := map[int64]gdb.Record{} + idSet := map[int64]struct{}{} + for _, r := range records { + if uid := r["user_id"].Int64(); uid > 0 { + idSet[uid] = struct{}{} + } + } + if len(idSet) == 0 { + return out + } + ids := make([]int64, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + rows, err := g.Model(consts.TableUsers).WhereIn("id", ids).All() + if err != nil { + return out + } + for _, u := range rows { + out[u["id"].Int64()] = u + } + return out +} + +// orderTitle 计算订单商品展示名。 +// +// 会员套餐 → 套餐名;次数包 → 工具名 · 档位名(工具名缺失时仅档位名)。 +func orderTitle( + orderType int, planKey, packKey, toolKey string, + planNames, packNames, toolNames map[string]string, +) string { + if orderType == consts.OrderTypeQuota { + title := packNames[packKey] + if tn := toolNames[toolKey]; tn != "" && title != "" { + title = tn + " · " + title + } + return title + } + return planNames[planKey] +} + +// AdminOrderRefund 订单退款(可选回收所购服务)。 +func AdminOrderRefund(ctx context.Context, outTradeNo string, revokeService bool) (*v1.OrderRefundRes, error) { + outTradeNo = strings.TrimSpace(outTradeNo) + if outTradeNo == "" { + return nil, errOrderInvalid("订单号不能为空") + } + res := &v1.OrderRefundRes{OutTradeNo: outTradeNo} + var beforeStatus int + + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + // 行锁:并发的两次退款串行化,保证「仅一次真正回收权益」 + row, err := tx.Model(consts.TableMemberOrders). + Where("out_trade_no", outTradeNo).LockUpdate().One() + if err != nil { + return err + } + if row.IsEmpty() { + return errOrderInvalid("订单不存在") + } + o := orderSnapshotFromRecord(row) + beforeStatus = o.Status + + switch o.Status { + case consts.OrderStatusRefunded: + // 幂等:已退款直接成功返回,不重复回收权益 + res.AlreadyRefunded = true + res.ServiceRevoked = row["service_revoked"].Int() == 1 + res.Status = consts.OrderStatusRefunded + res.StatusText = orderStatusText(consts.OrderStatusRefunded) + return nil + case consts.OrderStatusPending: + return errOrderInvalid("订单尚未支付,无法退款") + case consts.OrderStatusClosed: + return errOrderInvalid("订单已关闭,无法退款") + case consts.OrderStatusDelivered: + // 唯一允许退款的起点状态,继续 + default: + return errOrderInvalid("订单状态异常,无法退款") + } + + // 1) 回收所购服务(可选)—— 按**实际回收结果**决定 service_revoked, + // 而非「管理员是否勾选」:勾了但用户当前无可回收的生效授权时记 0(避免误导管理员)。 + serviceRevoked := 0 + if revokeService { + revoked, rerr := revokeOrderServiceTx(ctx, tx, o) + if rerr != nil { + return rerr + } + if revoked { + serviceRevoked = 1 + } + } + // 2) 释放该订单占用的免单券 / 优惠码(仅 1→2 时执行一次) + if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil { + return err + } + // 3) 状态与回收标记落库。 + // ⚠️ refunded_at / updated_at 用**库内 NOW()**(gdb.Raw)而非 Go 侧传参: + // 后台订单列表会展示退款时间,Go 传参落库会早一个时区(TZ-01,8h 可见偏差)。 + if _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{ + "status": consts.OrderStatusRefunded, + "service_revoked": serviceRevoked, + "refunded_at": gdb.Raw("NOW()"), + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return err + } + res.Revoked = true + res.ServiceRevoked = serviceRevoked == 1 + res.Status = consts.OrderStatusRefunded + res.StatusText = orderStatusText(consts.OrderStatusRefunded) + return nil + }) + if err != nil { + return nil, err + } + + if res.AlreadyRefunded { + res.Message = "订单已是退款状态,未重复回收权益" + } else if revokeService && res.ServiceRevoked { + res.Message = "退款成功,已回收所购服务" + } else if revokeService { + res.Message = "退款成功,未发现可回收的生效授权" + } else { + res.Message = "退款成功,所购服务已保留" + } + + // 审计(旁路:失败仅告警,不影响主流程) + WriteAudit(ctx, AuditEntry{ + Action: "order.refund", + TargetType: "order", + TargetId: outTradeNo, + Before: g.Map{"status": beforeStatus}, + After: g.Map{ + "status": consts.OrderStatusRefunded, + "revoke_service": revokeService, + "service_revoked": res.ServiceRevoked, + "idempotent": res.AlreadyRefunded, + }, + Result: consts.AuditResultSuccess, + Remark: res.Message, + }) + return res, nil +} + +// revokeOrderServiceTx 回收订单所购服务(仅已知订单行快照,事务内、行锁已持有)。 +// +// 返回 revoked = 是否确有「生效中的授权」被解除,供 service_revoked 按**实际结果**落库。 +func revokeOrderServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) { + if o.OrderType == consts.OrderTypeQuota { + return revokeQuotaServiceTx(ctx, tx, o) + } + return revokeMemberServiceTx(ctx, tx, o) +} + +// revokeQuotaServiceTx 次数包回收:扣回对应工具付费次数(扣到 0 为止,不出负数)。 +// +// 不变量:total_bought >= times_left(扣减 total_bought 时不低于扣减后的 times_left), +// 使看板「付费额度累计消耗 = Σ(total_bought - times_left)」保持自洽。 +// +// 返回 revoked = 实际扣回了「生效中的付费额度」(原 times_left > 0): +// 无额度记录 / 额度已耗尽(=0) 时不产生实际回收,返回 false。 +func revokeQuotaServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) { + if o.ToolKey == "" || o.Times <= 0 { + // 数据异常(正常发货要求 toolKey 非空且 times>0):无可回收项,跳过并告警 + g.Log().Warningf(ctx, "[refund] 次数包订单缺少工具/次数,跳过回收 outTradeNo=%s toolKey=%q times=%d", + o.OutTradeNo, o.ToolKey, o.Times) + return false, nil + } + row, err := tx.Model(consts.TableUserToolQuota). + Where("user_id", o.UserId).Where("tool_key", o.ToolKey).LockUpdate().One() + if err != nil { + return false, err + } + if row.IsEmpty() { + // 该用户在该工具上已无付费额度记录(可能已过期清理):无可回收项 + return false, nil + } + id := row["id"].Int64() + left := row["times_left"].Int() + bought := row["total_bought"].Int() + newLeft := left - o.Times + if newLeft < 0 { + newLeft = 0 // 扣到 0 为止,绝不为负 + } + newBought := bought - o.Times + if newBought < newLeft { + newBought = newLeft + } + if _, err = tx.Model(consts.TableUserToolQuota).Where("id", id).Data(g.Map{ + "times_left": newLeft, + "total_bought": newBought, + // ⚠️ 库内 NOW():避免 Go 侧传参 datetime 的 TZ-01 8h 偏差(新代码不得引入)。 + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return false, err + } + g.Log().Infof(ctx, "[refund] 次数包回收 userId=%d tool=%s -%d 次(剩余 %d→%d)outTradeNo=%s", + o.UserId, o.ToolKey, o.Times, left, newLeft, o.OutTradeNo) + // 原本就有可用额度才构成「生效中的授权被解除」;额度已为 0 时扣减不产生实际回收。 + return left > 0, nil +} + +// revokeMemberServiceTx 会员套餐回收:回退 users.level_expire_at。 +// +// 回收模型:发货时按「购买天数」顺延有效期(extendMembership),回收即**反向减去本单天数**。 +// - level_expire_at 为 NULL(永久)→ 按用户裁定(2026-09-23)**同样真取消、置为已过期**: +// 写入 level_expire_at = NOW();level_key **保留不清空**(失效判定交给 EffectiveLevelKey)。 +// - 有限期:减去本单天数;减完后若已到期(<= now)→ 夹到当前时刻(保持非 NULL), +// EffectiveLevelKey 随即回落默认等级。 +// - 等级回落**只经 EffectiveLevelKey 收敛**(与「自然到期」口径一致),本函数不改 level_key。 +// +// ⚠️ 时间取值遵循 TZ-01 铁律:本库 DSN 未指定 loc,Go 侧**传参** datetime 会按 UTC 落库 +// (比真实墙钟早一个时区偏移),Go 读回又被按本地重解释,形成写入/读取不对称的 8h 偏差 +// (本项目出过真事故)。故本函数**不把 Go 侧时间作为 datetime 列写入参数**: +// - 永久分支「置为已过期」用**库内 NOW()** 写入(DB 侧取值,写入/读取对称); +// - 限时分支的 `level_expire_at` 减法与 `updated_at` 均在库内完成(updated_at 用 NOW()); +// **唯一豁免**:`GREATEST(level_expire_at - INTERVAL ? DAY, ?)` 的夹取下限 `?` 保留 Go 传参 +// —— 它是「已过期哨兵值」,与库内 NOW() 在 EffectiveLevelKey 下用户可见行为完全一致。 +// +// 返回 revoked = 回收**前**是否为「生效中的会员」(唯一判定走 EffectiveLevelKey), +// 供 service_revoked 按实际结果落库(已过期 / 免费用户退款时不记「已回收」)。 +func revokeMemberServiceTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (bool, error) { + row, err := tx.Model(consts.TableUsers).Where("id", o.UserId).LockUpdate().One() + if err != nil { + return false, err + } + if row.IsEmpty() { + return false, nil + } + expire := row["level_expire_at"].GTime() + // 回收前是否为「生效中的会员」:唯一判定走 EffectiveLevelKey(永久 NULL 与未过期同为有效,已过期回落默认)。 + wasEffective := EffectiveLevelKey(&entity.Users{ + LevelKey: row["level_key"].String(), + LevelExpireAt: expire, + }) != consts.DefaultLevelKey + + // 永久会员:真取消,置为已过期(用户裁定 2026-09-23)。 + // NOW() 在库内取,规避 Go 侧传参的时区偏差(TZ-01);WHERE 限定 IS NULL,绝不误动有限期。 + if expire == nil || expire.IsZero() { + if _, err = tx.Exec( + "UPDATE "+consts.TableUsers+" SET level_expire_at = NOW(), updated_at = NOW() "+ + "WHERE id = ? AND level_expire_at IS NULL", + o.UserId); err != nil { + return false, err + } + g.Log().Infof(ctx, "[refund] 永久会员回收置为已过期 userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo) + return wasEffective, nil + } + days := o.DurationDays + if days <= 0 { + g.Log().Warningf(ctx, "[refund] 会员订单缺少有效天数,跳过会员回收 userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo) + return false, nil + } + now := gtime.Now() + // GREATEST(expire - days, now):正常回退 days 天;若回收后已到期则夹到当前时刻(非 NULL)。 + // 直连 SQL(不经过 Go 侧读回-改-写),规避 TZ-01 时区漂移;WHERE 限定非空,绝不误动永久会员。 + // + // 注:`GREATEST` 的**夹取下限 `?`** 依裁定保留 Go 传参——它是「已过期哨兵值」, + // 与库内 NOW() 写法在 `EffectiveLevelKey` 下用户可见行为完全一致(严格 `<` ⇒ 立即回落默认); + // 而 `updated_at` 是**展示列**(后台用户列表/详情可见),改用库内 NOW(),避免 Go 传参的 8h 偏差。 + result, err := tx.Exec( + "UPDATE "+consts.TableUsers+" SET level_expire_at = GREATEST(level_expire_at - INTERVAL ? DAY, ?), "+ + "updated_at = NOW() WHERE id = ? AND level_expire_at IS NOT NULL", + days, now, o.UserId) + if err != nil { + return false, err + } + if n, _ := result.RowsAffected(); n == 0 { + g.Log().Warningf(ctx, "[refund] 会员回收未命中(用户已无有限期)userId=%d outTradeNo=%s", o.UserId, o.OutTradeNo) + return false, nil + } + g.Log().Infof(ctx, "[refund] 会员回收 userId=%d -%d 天 outTradeNo=%s", o.UserId, days, o.OutTradeNo) + return wasEffective, nil +} diff --git a/internal/logic/admin_order_test.go b/internal/logic/admin_order_test.go new file mode 100644 index 0000000..e19a65e --- /dev/null +++ b/internal/logic/admin_order_test.go @@ -0,0 +1,536 @@ +package logic + +import ( + "context" + "fmt" + "os" + "sync" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ensureTestSchema 保证 member_orders 的退款相关列已存在。 +// 说明:GoFrame 的 Update(Data(map)) 会**按表结构过滤掉未知列**(静默丢弃), +// 故运行期迁移 Migrate() 必须先执行,service_revoked / refunded_at 才会真正落库。 +var migrateOnce sync.Once + +func ensureTestSchema() { + migrateOnce.Do(func() { Migrate(context.Background()) }) +} + +// ============================================================================ +// 订单退款 / 回收所购服务 · 集成测试(真实库,自建 fixture 并清理) +// +// 覆盖:会员套餐回收(减天数)、次数包回收(扣到 0 不为负)、不回收(权益保留)、 +// 幂等(第二次调用不重复回收)、列表关键字命中。 +// ============================================================================ + +func newTag(prefix string) string { return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) } + +func insertRefundUser(t *testing.T, tag, levelKey string, expire *gtime.Time) int64 { + t.Helper() + now := gtime.Now() + id, err := g.Model(consts.TableUsers).Data(g.Map{ + "openid": "refund_" + tag, + "username": "refund_" + tag, + "nickname": "退款测试" + tag, + "level_key": levelKey, + "level_expire_at": expire, // nil → 写 NULL(永久) + "status": 1, + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + t.Fatalf("插入测试用户失败: %v", err) + } + return id +} + +func insertDeliveredOrder(t *testing.T, userId int64, tag string, orderType int, fields g.Map) string { + t.Helper() + no := "TREF" + tag + now := gtime.Now() + data := g.Map{ + "out_trade_no": no, + "wx_order_id": "wx_" + no, + "user_id": userId, + "openid": "refund_" + tag, + "order_type": orderType, + "plan_key": "", + "level_key": "", + "product_id": "test_product", + "price_cents": 1000, + "origin_price_cents": 1000, + "paid_price_cents": 1000, + "discount_cents": 0, + "promo_kind": 0, + "status": consts.OrderStatusDelivered, + "pay_channel": consts.PayChannelWx, + "created_at": now, + "updated_at": now, + "paid_at": now, + "delivered_at": now, + } + for k, v := range fields { + data[k] = v + } + if _, err := g.Model(consts.TableMemberOrders).Data(data).Insert(); err != nil { + t.Fatalf("插入测试订单失败: %v", err) + } + return no +} + +func cleanupRefundFixture(t *testing.T, uid int64, orderNos ...string) { + t.Helper() + for _, no := range orderNos { + if _, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).Delete(); err != nil { + t.Logf("[cleanup] 删除订单 %s 失败: %v", no, err) + } + } + g.Model(consts.TableUserToolQuota).Where("user_id", uid).Delete() + g.Model(consts.TableUsers).Where("id", uid).Delete() +} + +// TestAdminOrderRefund_RevokeMemberService 会员套餐退款回收:减去本单天数 + 幂等。 +func TestAdminOrderRefund_RevokeMemberService(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + tag := newTag("rm") + uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid, no) + + // 读回基线(读回存在已知时区偏移,故一律用「读回 vs 读回」比较,偏移自然抵消) + baseU, _ := g.Model(consts.TableUsers).Where("id", uid).One() + baseline := baseU["level_expire_at"].GTime() + + res, err := AdminOrderRefund(ctx, no, true) + if err != nil { + t.Fatalf("退款失败: %v", err) + } + if !res.Revoked || !res.ServiceRevoked { + t.Fatalf("期望 revoke=true 且执行退款,得 %+v", res) + } + + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() + if row["status"].Int() != consts.OrderStatusRefunded { + t.Fatalf("订单状态应为已退款(2),得 %d", row["status"].Int()) + } + if row["service_revoked"].Int() != 1 { + t.Fatalf("service_revoked 应为 1,得 %d", row["service_revoked"].Int()) + } + if row["refunded_at"].GTime() == nil || row["refunded_at"].GTime().IsZero() { + t.Fatalf("refunded_at 应已写入") + } + + u, _ := g.Model(consts.TableUsers).Where("id", uid).One() + newExpire := u["level_expire_at"].GTime() + want := baseline.AddDate(0, 0, -30) + if newExpire == nil { + t.Fatalf("回收后期望仍为有限期(非 NULL)") + } + if diff := newExpire.Time.Sub(want.Time); diff < -2*time.Minute || diff > 2*time.Minute { + t.Fatalf("到期时间应回退 30 天:期望≈%s,得 %s", want.String(), newExpire.String()) + } + if u["level_key"].String() != "v2" { + t.Fatalf("回收后仍未到期,等级不应降级,得 %q", u["level_key"].String()) + } + + // 幂等:再次退款返回成功但不重复回收 + res2, err := AdminOrderRefund(ctx, no, true) + if err != nil { + t.Fatalf("幂等退款失败: %v", err) + } + if !res2.AlreadyRefunded || res2.Revoked { + t.Fatalf("第二次应为幂等命中,得 %+v", res2) + } + u2, _ := g.Model(consts.TableUsers).Where("id", uid).One() + if !u2["level_expire_at"].GTime().Time.Equal(newExpire.Time) { + t.Fatalf("幂等调用不应重复回收:%s → %s", newExpire.String(), u2["level_expire_at"].String()) + } +} + +// TestAdminOrderRefund_RevokeMemberExpiredDowngrade 回收后已到期 → 期限置当前时刻并回落默认等级。 +func TestAdminOrderRefund_RevokeMemberExpiredDowngrade(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + tag := newTag("rd") + uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 10)) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid, no) + + if _, err := AdminOrderRefund(ctx, no, true); err != nil { + t.Fatalf("退款失败: %v", err) + } + u, _ := g.Model(consts.TableUsers).Where("id", uid).One() + expire := u["level_expire_at"].GTime() + if expire == nil { + t.Fatalf("回收后期限不应为 NULL(不得写成永久)") + } + if expire.After(gtime.Now()) { + t.Fatalf("回收后应已到期,得 %s", expire.String()) + } + // 等级回落经 EffectiveLevelKey 收敛(不直接改写 level_key,与自然到期口径一致) + eff := EffectiveLevelKey(&entity.Users{LevelKey: u["level_key"].String(), LevelExpireAt: expire}) + if eff != consts.DefaultLevelKey { + t.Fatalf("回收后已到期,生效等级应回落默认,得 %q", eff) + } +} + +// TestAdminOrderRefund_RevokeQuotaClamp 次数包退款回收:扣到 0 为止,不为负。 +func TestAdminOrderRefund_RevokeQuotaClamp(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + tag := newTag("rq") + toolKey := "refund_test_tool_" + tag + uid := insertRefundUser(t, tag, consts.DefaultLevelKey, nil) + no1 := insertDeliveredOrder(t, uid, tag+"a", consts.OrderTypeQuota, g.Map{ + "pack_key": "pack5", "tool_key": toolKey, "times": 5, "duration_days": 365, + }) + no2 := insertDeliveredOrder(t, uid, tag+"b", consts.OrderTypeQuota, g.Map{ + "pack_key": "pack10", "tool_key": toolKey, "times": 10, "duration_days": 365, + }) + defer cleanupRefundFixture(t, uid, no1, no2) + + now := gtime.Now() + if _, err := g.Model(consts.TableUserToolQuota).Data(g.Map{ + "user_id": uid, "tool_key": toolKey, "times_left": 8, "total_bought": 8, + "expire_at": now.AddDate(0, 0, 365), "created_at": now, "updated_at": now, + }).Insert(); err != nil { + t.Fatalf("插入额度失败: %v", err) + } + + // 第一次:8 - 5 = 3 + if _, err := AdminOrderRefund(ctx, no1, true); err != nil { + t.Fatalf("退款失败: %v", err) + } + q, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid).Where("tool_key", toolKey).One() + if q["times_left"].Int() != 3 { + t.Fatalf("次数回收后应剩 3,得 %d", q["times_left"].Int()) + } + + // 第二次:3 - 10 → 夹到 0(不为负) + if _, err := AdminOrderRefund(ctx, no2, true); err != nil { + t.Fatalf("退款失败: %v", err) + } + q2, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid).Where("tool_key", toolKey).One() + if q2["times_left"].Int() != 0 { + t.Fatalf("次数应扣到 0 为止,得 %d", q2["times_left"].Int()) + } + if q2["total_bought"].Int() < q2["times_left"].Int() { + t.Fatalf("total_bought 不应小于 times_left:%d < %d", q2["total_bought"].Int(), q2["times_left"].Int()) + } +} + +// TestAdminOrderRefund_PermanentMemberRevoked 永久会员(level_expire_at IS NULL)退款回收: +// 用户裁定(2026-09-23)—— 管理员勾选「取消服务」时,永久授权**同样真取消、置为已过期**。 +// +// 关键反转点(相对旧行为「保留服务」): +// 1. level_expire_at 不再为 NULL,且 ≈ 当前时刻(由 DB 侧 NOW() 写入,读写同源、无 8h 时区偏差); +// 2. EffectiveLevelKey 随即回落默认等级(降级生效); +// 3. level_key 字段本身**保留原值**(失效判定交给 EffectiveLevelKey,不为永久会员加特例清空)。 +func TestAdminOrderRefund_PermanentMemberRevoked(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + tag := newTag("pm") + uid := insertRefundUser(t, tag, "v2", nil) // level_expire_at = NULL → 永久 + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid, no) + + before := gtime.Now() + res, err := AdminOrderRefund(ctx, no, true) + if err != nil { + t.Fatalf("退款失败: %v", err) + } + if !res.Revoked || !res.ServiceRevoked { + t.Fatalf("期望 revoke=true 且确已回收永久授权,得 %+v", res) + } + + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() + if row["status"].Int() != consts.OrderStatusRefunded { + t.Fatalf("订单状态应为已退款(2),得 %d", row["status"].Int()) + } + if row["service_revoked"].Int() != 1 { + t.Fatalf("service_revoked 应为 1,得 %d", row["service_revoked"].Int()) + } + + u, _ := g.Model(consts.TableUsers).Where("id", uid).One() + expire := u["level_expire_at"].GTime() + // 关键反转点 1:永久(NULL)已被置为「当前时刻」,不再为 NULL + if expire == nil || expire.IsZero() { + t.Fatalf("永久会员退款回收后 level_expire_at 不应再为 NULL") + } + // DB 侧 NOW() 写入 + 读侧同源,容许 ±2 分钟 + if d := expire.Time.Sub(before.Time); d < -2*time.Minute || d > 2*time.Minute { + t.Fatalf("level_expire_at 应≈当前时刻:期望≈%s,得 %s", before.String(), expire.String()) + } + // 关键反转点 3:level_key 字段本身保留原值,不清空 + if u["level_key"].String() != "v2" { + t.Fatalf("level_key 字段应保留原值 v2,得 %q", u["level_key"].String()) + } + // 关键反转点 2:生效等级已回落默认(降级生效)。 + // EffectiveLevelKey 内部以严格 `<` 判过期;而取消时刻由 DB 侧 NOW() 写入,因秒级截断与 + // DB/进程时钟的亚秒偏差,读回值可能恰落在「当前秒」甚至略晚。故先等待进程时钟越过该时刻, + // 再验证降级已生效(<= ~2s,确定性,不受 DB 时钟偏差影响)。 + if wait := time.Until(expire.Time.Add(time.Second)); wait > 0 { + time.Sleep(wait) + } + if eff := EffectiveLevelKey(&entity.Users{LevelKey: u["level_key"].String(), LevelExpireAt: expire}); eff != consts.DefaultLevelKey { + t.Fatalf("永久会员被回收后生效等级应回落默认,得 %q", eff) + } +} + +// TestAdminOrderRefund_RevokeButNothingToReclaim 勾选「取消服务」但用户当前无生效会员(已过期): +// service_revoked 按**实际回收结果**记录 → 0(列表不显示「已回收」,避免误导管理员)。 +func TestAdminOrderRefund_RevokeButNothingToReclaim(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + tag := newTag("rn") + // level_key=v2 但 level_expire_at 早于当前时刻 → 当前无生效会员(已过期) + uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, -1)) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid, no) + + res, err := AdminOrderRefund(ctx, no, true) // 明确勾选回收 + if err != nil { + t.Fatalf("退款失败: %v", err) + } + if !res.Revoked { + t.Fatalf("订单应被标记退款,得 %+v", res) + } + if res.ServiceRevoked { + t.Fatalf("用户当前无生效会员,service_revoked 应为 false,得 %+v", res) + } + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() + if row["status"].Int() != consts.OrderStatusRefunded { + t.Fatalf("订单状态应为已退款(2),得 %d", row["status"].Int()) + } + if row["service_revoked"].Int() != 0 { + t.Fatalf("无可回收的生效授权时 service_revoked 应为 0,得 %d", row["service_revoked"].Int()) + } + // 权益本就无效,退款回收后仍为「已过期」(不产生新的生效会员) + u, _ := g.Model(consts.TableUsers).Where("id", uid).One() + eff := EffectiveLevelKey(&entity.Users{ + LevelKey: u["level_key"].String(), + LevelExpireAt: u["level_expire_at"].GTime(), + }) + if eff != consts.DefaultLevelKey { + t.Fatalf("已过期会员退款回收后不应变为生效,得 %q", eff) + } +} + +// TestAdminOrderRefund_NewWritesNoTZ01Drift 钉住「新代码不得引入 TZ-01」: +// 退款流程新引入的 datetime 写入(订单 refunded_at / updated_at、额度 updated_at、永久会员 level_expire_at) +// 一律走库内 NOW(),库读回后应≈当前墙钟(±5s),而**不是**早 8h。 +func TestAdminOrderRefund_NewWritesNoTZ01Drift(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + + // ---- (1) 会员单退款:订单 refunded_at / updated_at ≈ 墙钟 ---- + tag := newTag("tzm") + uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid, no) + + before := gtime.Now() + if _, err := AdminOrderRefund(ctx, no, true); err != nil { + t.Fatalf("退款失败: %v", err) + } + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() + refunded := row["refunded_at"].GTime() + if refunded == nil || refunded.IsZero() { + t.Fatalf("refunded_at 应写入且非零") + } + if d := refunded.Time.Sub(before.Time); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("refunded_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", refunded.String()) + } + if d := row["updated_at"].GTime().Time.Sub(before.Time); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("订单 updated_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", row["updated_at"].String()) + } + + // ---- (2) 永久会员退款:level_expire_at 非 NULL 且不晚于库内 NOW() ---- + tag2 := newTag("tzp") + uid2 := insertRefundUser(t, tag2, "v2", nil) // 永久 + no2 := insertDeliveredOrder(t, uid2, tag2, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid2, no2) + + before2 := gtime.Now() + if _, err := AdminOrderRefund(ctx, no2, true); err != nil { + t.Fatalf("退款失败: %v", err) + } + u2, _ := g.Model(consts.TableUsers).Where("id", uid2).One() + exp := u2["level_expire_at"].GTime() + if exp == nil || exp.IsZero() { + t.Fatalf("永久会员回收后 level_expire_at 不应为 NULL") + } + // 库内判定「不晚于 NOW()」:比 Go 侧严格 `<` 更稳,规避秒级边界。 + late, _ := g.DB().GetValue(ctx, + "SELECT (level_expire_at <= NOW()) FROM "+consts.TableUsers+" WHERE id = ?", uid2) + if !late.Bool() { + t.Fatalf("永久会员回收后 level_expire_at 应不晚于库内 NOW(),得 %s", exp.String()) + } + if d := exp.Time.Sub(before2.Time); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("level_expire_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", exp.String()) + } + + // ---- (3) 次数包退款:额度 updated_at ≈ 墙钟 ---- + tag3 := newTag("tzq") + toolKey := "tz_tool_" + tag3 + uid3 := insertRefundUser(t, tag3, consts.DefaultLevelKey, nil) + no3 := insertDeliveredOrder(t, uid3, tag3, consts.OrderTypeQuota, g.Map{ + "pack_key": "pack5", "tool_key": toolKey, "times": 5, "duration_days": 365, + }) + defer cleanupRefundFixture(t, uid3, no3) + + tn := gtime.Now() + if _, err := g.Model(consts.TableUserToolQuota).Data(g.Map{ + "user_id": uid3, "tool_key": toolKey, "times_left": 8, "total_bought": 8, + "expire_at": tn.AddDate(0, 0, 365), "created_at": tn, "updated_at": tn, + }).Insert(); err != nil { + t.Fatalf("插入额度失败: %v", err) + } + beforeQ := gtime.Now() + if _, err := AdminOrderRefund(ctx, no3, true); err != nil { + t.Fatalf("退款失败: %v", err) + } + q, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid3).Where("tool_key", toolKey).One() + if d := q["updated_at"].GTime().Time.Sub(beforeQ.Time); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("额度 updated_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", q["updated_at"].String()) + } +} + +// TestAdminOrderRefund_NoRevokeKeepsService 不回收服务:仅改状态,权益保留。 +func TestAdminOrderRefund_NoRevokeKeepsService(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + tag := newTag("nr") + uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid, no) + + baseU, _ := g.Model(consts.TableUsers).Where("id", uid).One() + baseline := baseU["level_expire_at"].GTime() + + res, err := AdminOrderRefund(ctx, no, false) + if err != nil { + t.Fatalf("退款失败: %v", err) + } + if !res.Revoked || res.ServiceRevoked { + t.Fatalf("不应回收服务,得 %+v", res) + } + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() + if row["status"].Int() != consts.OrderStatusRefunded || row["service_revoked"].Int() != 0 { + t.Fatalf("状态/service_revoked 不符:%d/%d", row["status"].Int(), row["service_revoked"].Int()) + } + u, _ := g.Model(consts.TableUsers).Where("id", uid).One() + if !u["level_expire_at"].GTime().Time.Equal(baseline.Time) { + t.Fatalf("不回收时会员期限应保持不变:%s → %s", baseline.String(), u["level_expire_at"].String()) + } +} + +// TestAdminOrderRefund_StatusGuard 非已发货订单退款被拒(可读业务错误,4015)。 +func TestAdminOrderRefund_StatusGuard(t *testing.T) { + ensureTestSchema() + ctx := context.Background() + tag := newTag("sg") + uid := insertRefundUser(t, tag, consts.DefaultLevelKey, nil) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + "status": consts.OrderStatusPending, + }) + defer cleanupRefundFixture(t, uid, no) + + if _, err := AdminOrderRefund(ctx, no, true); err == nil { + t.Fatalf("待支付订单退款应被拒绝") + } +} + +// TestAdminOrderList_Keyword 列表关键字命中订单号,且返回商品名与用户信息。 +func TestAdminOrderList_Keyword(t *testing.T) { + ctx := context.Background() + tag := newTag("ls") + uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + defer cleanupRefundFixture(t, uid, no) + + res, err := AdminOrderList(ctx, &v1.OrderListReq{Keyword: no}) + if err != nil { + t.Fatalf("列表失败: %v", err) + } + if res.Total < 1 { + t.Fatalf("按订单号应至少命中 1 条,得 %d", res.Total) + } + found := false + for _, it := range res.List { + if it.OutTradeNo == no { + found = true + if it.Nickname == "" || it.UserId != uid { + t.Fatalf("应带归属用户信息,得 %+v", it) + } + if it.StatusText == "" { + t.Fatalf("应带状态文案") + } + } + } + if !found { + t.Fatalf("未在列表中找到目标订单 %s", no) + } +} + +// ============================================================================ +// 供 curl 实测用的可选 fixture(默认不运行;设 SEED_REFUND=1 时插入并保留) +// ============================================================================ + +func TestAdminOrderSeedForCurl(t *testing.T) { + if os.Getenv("SEED_REFUND") != "1" { + t.Skip("未设置 SEED_REFUND=1,跳过 curl fixture 播种") + } + tag := "curl" + fmt.Sprintf("%d", time.Now().UnixNano()) + uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) + no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ + "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, + }) + fmt.Printf("SEED_REFUND uid=%d out_trade_no=%s\n", uid, no) +} + +// TestAdminOrderCleanupForCurl 清理 curl fixture(设 CLEAN_REFUND=1 + REFUND_ORDER_NO/REFUND_UID)。 +func TestAdminOrderCleanupForCurl(t *testing.T) { + if os.Getenv("CLEAN_REFUND") != "1" { + t.Skip("未设置 CLEAN_REFUND=1,跳过 curl fixture 清理") + } + no := os.Getenv("REFUND_ORDER_NO") + uid := os.Getenv("REFUND_UID") + if no != "" { + if _, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).Delete(); err != nil { + t.Logf("删除订单失败: %v", err) + } + } + if uid != "" { + g.Model(consts.TableUserToolQuota).Where("user_id", uid).Delete() + g.Model(consts.TableUsers).Where("id", uid).Delete() + } + fmt.Printf("CLEAN_REFUND done no=%s uid=%s\n", no, uid) +} diff --git a/internal/logic/admin_perm.go b/internal/logic/admin_perm.go new file mode 100644 index 0000000..cdcd5ae --- /dev/null +++ b/internal/logic/admin_perm.go @@ -0,0 +1,207 @@ +package logic + +import ( + "context" + "net/http" + "sort" + "strings" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + + "tool-api/internal/consts" +) + +// ============================================================================ +// 后台权限分级(T17) +// +// 模型:角色(超管/运营/只读)→ 权限点集合;中间件按「路由 → 权限点」放行。 +// 权限点与角色的对应关系集中在本文件,避免散落到每个 handler。 +// ============================================================================ + +// 权限点 +const ( + PermUserWrite = "user:write" + PermToolWrite = "tool:write" + PermModuleWrite = "module:write" + PermLevelWrite = "level:write" + PermFeedbackWrite = "feedback:write" + PermQuotaWrite = "quota:write" + PermEventWrite = "event:write" + PermAdminManage = "admin:manage" + PermAuditRead = "audit:read" + // 订单域:查看 / 退款(退款为可逆性资金动作,单独权限点便于收口) + PermOrderRead = "order:read" + PermOrderWrite = "order:write" +) + +// rolePermissions 角色 → 权限点集合。 +// +// 超管:全部 +// 运营:内容读写(用户/工具/模块/等级/反馈/额度/年会/订单),不含管理员管理与审计 +// 只读:无写权限,仅可查看(含订单查看) +var rolePermissions = map[string]map[string]bool{ + consts.AdminRoleSuper: { + PermUserWrite: true, PermToolWrite: true, PermModuleWrite: true, PermLevelWrite: true, + PermFeedbackWrite: true, PermQuotaWrite: true, PermEventWrite: true, + PermAdminManage: true, PermAuditRead: true, + PermOrderRead: true, PermOrderWrite: true, + }, + consts.AdminRoleOperator: { + PermUserWrite: true, PermToolWrite: true, PermModuleWrite: true, PermLevelWrite: true, + PermFeedbackWrite: true, PermQuotaWrite: true, PermEventWrite: true, + PermOrderRead: true, PermOrderWrite: true, + }, + consts.AdminRoleReadonly: { + PermOrderRead: true, + }, +} + +// HasPermission 判断角色是否拥有权限点;perm 为空表示「仅需登录」。 +// 未知角色(历史脏数据)→ 按最小权限处理(false),避免越权。 +func HasPermission(role, perm string) bool { + if perm == "" { + return true + } + perms, ok := rolePermissions[role] + if !ok { + return false + } + return perms[perm] +} + +// PermissionsOf 返回角色的权限点列表(前端据此隐藏入口),已排序。 +func PermissionsOf(role string) []string { + perms := rolePermissions[role] + out := make([]string, 0, len(perms)) + for p := range perms { + out = append(out, p) + } + sort.Strings(out) + return out +} + +// roleNameOf 角色值 → 展示名 +func roleNameOf(role string) string { + switch role { + case consts.AdminRoleSuper: + return "超级管理员" + case consts.AdminRoleOperator: + return "运营" + case consts.AdminRoleReadonly: + return "只读" + } + return role +} + +// permissionForRoute 路由 → 所需权限点。空串 = 仅需登录(只读接口)。 +// 采用「方法 + 路径前缀」映射;/user/quota-period 是额度操作,需先于 /user/ 命中。 +func permissionForRoute(method, path string) string { + if method != http.MethodPost { + if strings.HasPrefix(path, "/audit/") { + return PermAuditRead + } + if strings.HasPrefix(path, "/admin/") { + return PermAdminManage + } + if strings.HasPrefix(path, "/order/") { + return PermOrderRead + } + return "" + } + switch { + case path == "/user/quota-period": + return PermQuotaWrite + case path == "/audit/record": + // 上报审计≠读取审计:任何登录的管理员都可记录自己的动作(如导出) + return "" + case strings.HasPrefix(path, "/user/"): + return PermUserWrite + case strings.HasPrefix(path, "/tools/"): + return PermToolWrite + case strings.HasPrefix(path, "/module/"): + return PermModuleWrite + case strings.HasPrefix(path, "/level/"): + return PermLevelWrite + case strings.HasPrefix(path, "/feedback/"): + return PermFeedbackWrite + case strings.HasPrefix(path, "/quota/"), strings.HasPrefix(path, "/plan/"): + return PermQuotaWrite + case strings.HasPrefix(path, "/promo/"), strings.HasPrefix(path, "/coupon/"): + // 优惠码 / 优惠券属商业化配置,归入「额度写」权限(运营+超管可写;只读不可写) + return PermQuotaWrite + case strings.HasPrefix(path, "/event/"): + return PermEventWrite + case strings.HasPrefix(path, "/order/"): + // 订单退款属可逆性资金动作,按「订单写」权限放行(运营+超管可写;只读不可写) + return PermOrderWrite + case strings.HasPrefix(path, "/admin/"): + return PermAdminManage + case strings.HasPrefix(path, "/audit/"): + return PermAuditRead + } + return "" +} + +func writeForbidden(r *ghttp.Request, message string) { + r.Response.WriteJson(g.Map{"code": 403, "message": message, "result": nil}) + r.Exit() +} + +// AdminPerm 权限中间件(须挂在 AdminAuth 之后)。 +// 从库中复核管理员最新状态与角色(使「停用/改角色」立即生效,无需重新登录), +// 按「路由 → 权限点」放行;管理员信息写入上下文,供审计复用。 +func AdminPerm(r *ghttp.Request) { + adminId := r.GetCtxVar(consts.CtxAdminId).Int64() + record, err := g.Model(consts.TableAdminUsers).Where("id", adminId).One() + if err != nil { + // 鉴权已由 AdminAuth 完成;DB 抖动不应把所有人挡在门外 → 放行并告警 + g.Log().Warningf(r.Context(), "[adminperm] 读取管理员失败,放行: %v", err) + r.Middleware.Next() + return + } + if record.IsEmpty() { + writeForbidden(r, "管理员不存在,请重新登录") + return + } + // status 列可能因迁移未执行而缺失:缺失视为启用(fail-open),避免全站被锁 + if v, ok := record["status"]; ok && v.Int() != consts.AdminStatusEnabled { + writeForbidden(r, "账号已停用") + return + } + role := record["role_value"].String() + if !HasPermission(role, permissionForRoute(r.Method, r.URL.Path)) { + writeForbidden(r, "当前角色无此操作权限") + return + } + r.SetCtxVar(consts.CtxAdminRole, role) + r.SetCtxVar(consts.CtxAdminAccount, record["account"].String()) + r.SetCtxVar(consts.CtxAdminName, record["nick_name"].String()) + r.Middleware.Next() +} + +// ===== 审计上下文取值 ===== + +func CtxAdminRole(ctx context.Context) string { + req := g.RequestFromCtx(ctx) + if req == nil { + return "" + } + return req.GetCtxVar(consts.CtxAdminRole).String() +} + +func CtxAdminAccount(ctx context.Context) string { + req := g.RequestFromCtx(ctx) + if req == nil { + return "" + } + return req.GetCtxVar(consts.CtxAdminAccount).String() +} + +func CtxAdminName(ctx context.Context) string { + req := g.RequestFromCtx(ctx) + if req == nil { + return "" + } + return req.GetCtxVar(consts.CtxAdminName).String() +} diff --git a/internal/logic/admin_perm_test.go b/internal/logic/admin_perm_test.go new file mode 100644 index 0000000..f6ffb9f --- /dev/null +++ b/internal/logic/admin_perm_test.go @@ -0,0 +1,102 @@ +package logic + +import ( + "net/http" + "testing" + + "tool-api/internal/consts" +) + +// TestHasPermission 角色 → 权限点判定。 +func TestHasPermission(t *testing.T) { + // 空权限点 = 仅需登录 → 任何角色通过 + if !HasPermission(consts.AdminRoleReadonly, "") { + t.Fatal("空权限点应对所有角色放行") + } + // 超管:全部 + if !HasPermission(consts.AdminRoleSuper, PermAdminManage) || + !HasPermission(consts.AdminRoleSuper, PermEventWrite) || + !HasPermission(consts.AdminRoleSuper, PermAuditRead) { + t.Fatal("超管应拥有全部权限") + } + // 运营:有内容写权限,无管理员管理与审计 + if !HasPermission(consts.AdminRoleOperator, PermUserWrite) || + !HasPermission(consts.AdminRoleOperator, PermEventWrite) { + t.Fatal("运营应有内容写权限") + } + if HasPermission(consts.AdminRoleOperator, PermAdminManage) || + HasPermission(consts.AdminRoleOperator, PermAuditRead) { + t.Fatal("运营不应拥有管理员管理 / 审计权限") + } + // 只读:无任何写权限 + if HasPermission(consts.AdminRoleReadonly, PermUserWrite) || + HasPermission(consts.AdminRoleReadonly, PermAdminManage) { + t.Fatal("只读不应拥有写权限") + } + // 只读:可查看订单,但不可退款 + if !HasPermission(consts.AdminRoleReadonly, PermOrderRead) { + t.Fatal("只读应可查看订单") + } + if HasPermission(consts.AdminRoleReadonly, PermOrderWrite) { + t.Fatal("只读不应拥有订单退款权限") + } + // 未知角色:按最小权限,全部拒绝 + if HasPermission("ghost", PermUserWrite) { + t.Fatal("未知角色不应拥有权限") + } +} + +// TestPermissionsOf 权限点列表稳定且有序。 +func TestPermissionsOf(t *testing.T) { + if n := len(PermissionsOf(consts.AdminRoleSuper)); n != 11 { + t.Fatalf("超管权限点应为 11,得 %d", n) + } + if n := len(PermissionsOf(consts.AdminRoleReadonly)); n != 1 { + t.Fatalf("只读权限点应为 1(仅 order:read),得 %d", n) + } + pos := PermissionsOf(consts.AdminRoleOperator) + if len(pos) != 9 { + t.Fatalf("运营权限点应为 9,得 %d", len(pos)) + } + for i := 1; i < len(pos); i++ { + if pos[i-1] > pos[i] { + t.Fatalf("权限点未排序: %v", pos) + } + } +} + +// TestPermissionForRoute 路由 → 权限点映射。 +func TestPermissionForRoute(t *testing.T) { + cases := []struct { + method, path, want string + }{ + {http.MethodPost, "/user/set-level", PermUserWrite}, + {http.MethodPost, "/user/status", PermUserWrite}, + {http.MethodPost, "/user/batch", PermUserWrite}, + {http.MethodPost, "/user/quota-period", PermQuotaWrite}, // 额度操作优先于 /user/ 前缀 + {http.MethodPost, "/tools/save", PermToolWrite}, + {http.MethodPost, "/module/save", PermModuleWrite}, + {http.MethodPost, "/level/save", PermLevelWrite}, + {http.MethodPost, "/feedback/handle", PermFeedbackWrite}, + {http.MethodPost, "/quota/pack/save", PermQuotaWrite}, + {http.MethodPost, "/plan/save", PermQuotaWrite}, + {http.MethodPost, "/event/admin/status", PermEventWrite}, + {http.MethodPost, "/event/employee/import-confirm", PermEventWrite}, + {http.MethodPost, "/admin/save", PermAdminManage}, + {http.MethodPost, "/audit/record", ""}, // 上报审计仅需登录(≠读取审计) + {http.MethodPost, "/order/refund", PermOrderWrite}, + // 只读接口仅需登录 + {http.MethodGet, "/user/list", ""}, + {http.MethodGet, "/dashboard/stats", ""}, + {http.MethodGet, "/tools/all", ""}, + // 敏感只读接口需要权限 + {http.MethodGet, "/audit/list", PermAuditRead}, + {http.MethodGet, "/admin/list", PermAdminManage}, + {http.MethodGet, "/order/list", PermOrderRead}, + } + for _, c := range cases { + if got := permissionForRoute(c.method, c.path); got != c.want { + t.Errorf("%s %s → %q,期望 %q", c.method, c.path, got, c.want) + } + } +} diff --git a/internal/logic/admin_quota.go b/internal/logic/admin_quota.go index 0be911e..92e5fbd 100644 --- a/internal/logic/admin_quota.go +++ b/internal/logic/admin_quota.go @@ -58,7 +58,7 @@ func AdminQuotaPackList(ctx context.Context) (*v1.QuotaPackListRes, error) { func AdminQuotaPackSave(ctx context.Context, req *v1.QuotaPackSaveReq) (*v1.QuotaPackSaveRes, error) { if req.ToolKey != "" { - count, err := g.Model(consts.TableTools).Where("tool_key", req.ToolKey).Count() + count, err := g.Model(consts.TableTools).Where("tool_key", req.ToolKey).WhereNull("deleted_at").Count() if err != nil { return nil, err } @@ -200,11 +200,11 @@ func AdminPlanSave(ctx context.Context, req *v1.PlanSaveReq) (*v1.PlanSaveRes, e // ===== 用户额度周期 ===== func AdminUserQuotaPeriod(ctx context.Context, userId int64, periodDays int) (*v1.UserQuotaPeriodRes, error) { - count, err := g.Model(consts.TableUsers).Where("id", userId).Count() + before, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One() if err != nil { return nil, err } - if count == 0 { + if before.IsEmpty() { return nil, gerror.New("用户不存在") } if _, err = g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{ @@ -213,5 +213,13 @@ func AdminUserQuotaPeriod(ctx context.Context, userId int64, periodDays int) (*v }).Update(); err != nil { return nil, err } + WriteAudit(ctx, AuditEntry{ + Action: "user.set_quota_period", + TargetType: "user", + TargetId: auditId(userId), + Before: g.Map{"quota_period_days": before["quota_period_days"].Int()}, + After: g.Map{"quota_period_days": periodDays}, + Result: consts.AuditResultSuccess, + }) return &v1.UserQuotaPeriodRes{}, nil } diff --git a/internal/logic/admin_test.go b/internal/logic/admin_test.go new file mode 100644 index 0000000..35ec277 --- /dev/null +++ b/internal/logic/admin_test.go @@ -0,0 +1,67 @@ +package logic + +import "testing" + +// TestResolveSort_WhitelistHit 白名单命中的列应原样返回,方向按 asc/desc 归一。 +func TestResolveSort_WhitelistHit(t *testing.T) { + wl := map[string]string{"created_at": "created_at", "usage_count": "usage_count"} + + col, dir := resolveSort("created_at", "asc", wl, "id desc") + if col != "created_at" || dir != "asc" { + t.Fatalf("hit asc: got (%q,%q)", col, dir) + } + + // 方向大写也应被识别 + col, dir = resolveSort("usage_count", "ASC", wl, "id desc") + if col != "usage_count" || dir != "asc" { + t.Fatalf("hit ASC: got (%q,%q)", col, dir) + } +} + +// TestResolveSort_DefaultDirection 命中白名单但未给方向时,默认 desc。 +func TestResolveSort_DefaultDirection(t *testing.T) { + wl := map[string]string{"created_at": "created_at"} + col, dir := resolveSort("created_at", "", wl, "id desc") + if col != "created_at" || dir != "desc" { + t.Fatalf("default dir: got (%q,%q)", col, dir) + } +} + +// TestResolveSort_FallbackParsed fallback 形如 "列名 方向" 时按解析结果返回。 +func TestResolveSort_FallbackParsed(t *testing.T) { + wl := map[string]string{"created_at": "created_at"} + + col, dir := resolveSort("", "", wl, "id desc") + if col != "id" || dir != "desc" { + t.Fatalf("empty sortBy fallback: got (%q,%q)", col, dir) + } + + col, dir = resolveSort("nope", "", wl, "sort asc") + if col != "sort" || dir != "asc" { + t.Fatalf("unknown sortBy fallback: got (%q,%q)", col, dir) + } + + // 只有列名、无方向时默认 desc + col, dir = resolveSort("", "", wl, "sort") + if col != "sort" || dir != "desc" { + t.Fatalf("single-token fallback: got (%q,%q)", col, dir) + } +} + +// TestResolveSort_InjectionRejected 非法 sortBy(未在白名单)必须回落到 fallback, +// 绝不能把用户输入拼进 ORDER BY。 +func TestResolveSort_InjectionRejected(t *testing.T) { + wl := map[string]string{"created_at": "created_at"} + + for _, evil := range []string{ + "created_at; DROP TABLE users", + "id)--", + "(SELECT 1)", + "created_at DESC, id", + } { + col, _ := resolveSort(evil, "asc", wl, "id desc") + if col != "id" { + t.Fatalf("injection %q not rejected, got col=%q", evil, col) + } + } +} diff --git a/internal/logic/coupon.go b/internal/logic/coupon.go new file mode 100644 index 0000000..d3d1279 --- /dev/null +++ b/internal/logic/coupon.go @@ -0,0 +1,813 @@ +package logic + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 优惠凭证域 · 优惠券(迭代-2026-09-20,T04.4) +// +// 覆盖: +// - 管理端:券模板 CRUD(A6–A8)+ 发放/记录/核销(A9–A11) +// - 用户端:/coupon/my(P5)、/coupon/validate(P6,只读)、/coupon/usable(P7,只返免单券) +// - 下单事务内的免单券原子占用原语 occupyFreebie(已在 order_pay.go 的 applyVoucherToOrder 内接线) +// - 超时释放:ReleaseExpiredCouponLocks(gcron) +// +// 硬约束(约定 S7/S8): +// - 券型隔离:1 免单券(走支付)/ 2 展示券(仅展示);**新建默认 2**(fail-closed); +// /coupon/usable 只返免单券;展示券不出现在任何「去使用/抵扣」语义里。 +// - 已发券用快照:coupon_title/coupon_type/kind/value/threshold_cents 在发放时复制, +// 模板改名/改型/改面额**不影响已发券**。 +// - 「已过期」在**列表读取时**按 expire_at < now 判定(不落库,P0)。 +// ============================================================================ + +// couponStatusReason 券状态的可读原因。 +func couponStatusReason(status int) string { + switch status { + case consts.UserCouponStatusUsed: + return "该券已被使用" + case consts.UserCouponStatusExpired: + return "优惠券已过期" + case consts.UserCouponStatusVoid: + return "优惠券已作废" + default: + return "优惠券不可用" + } +} + +// couponExpirePassedExpr 判定「expire_at 早于绑定时刻(真=已过期)」的 SQL 片段。 +// +// expire_at 的过期与否一律由**数据库**按墙钟值比较:绑定的 gtime 参数与写入(同样以 gtime +// 参数落库)同源,规避「读侧恒定走 gtime.StrToTime(恒 Local)导致 Go 读回比真实早 8h」的 +// 时区错位(TZ-01)。语义:expire_at 非空且早于 now 即过期(NULL = 永久)。 +// +// 注:旧 Go 判定里还有「零值(0000-00-00)不算过期」的分支,但 MySQL(strict / NO_ZERO_DATE) +// 根本不允许写入零日期(比较零日期字面量还会直接报 1525),故该分支在库内无对应数据,删去后行为等价。 +const couponExpirePassedExpr = "(expire_at IS NOT NULL AND expire_at < ?)" + +// couponExpiredAt 由数据库判定「该券在 now 时刻是否已过期」(SQL 侧绑定 gtime 参数)。 +// m 可为 g.Model(...) 或 tx.Model(...):同一张表、同一连接语义。 +func couponExpiredAt(ctx context.Context, m *gdb.Model, id int64, now *gtime.Time) (bool, error) { + cnt, err := m.Where("id", id).Where(couponExpirePassedExpr, now).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// effectiveCouponStatus 读取态券状态:未使用但已过 expire_at → 视为「已过期」(不落库)。 +// +// expirePassed 由 **SQL 侧**判定后传入(TZ-01),本函数保持纯函数、不再自行比较时间。 +func effectiveCouponStatus(c *entity.UserCoupons, expirePassed bool) int { + if c.Status == consts.UserCouponStatusUnused && expirePassed { + return consts.UserCouponStatusExpired + } + return c.Status +} + +// couponUsableErr 免单券「支付可用性」判定(纯函数,不碰 DB)。 +// +// 仅 coupon_type=1(免单券)可走支付;展示券(2)一律不可用于抵扣(fail-closed)。 +// expired 由 **SQL 侧**判定后传入(TZ-01),本函数不再自行比较时间。 +func couponUsableErr(c *entity.UserCoupons, expired bool) error { + if c == nil { + return errCouponUnavailable("优惠券不存在") + } + if c.CouponType != consts.CouponTypeFreebie { + return errCouponUnavailable("该券为展示券,不可用于抵扣") + } + if c.Status != consts.UserCouponStatusUnused { + return errCouponUnavailable(couponStatusReason(c.Status)) + } + if expired { + return errCouponUnavailable("优惠券已过期") + } + return nil +} + +// buildUserCouponRow 由模板生成一条「已发券」行(含券面快照)。 +// +// 纯函数:快照字段在发放时从模板**取值复制**,之后模板改名/改型/改面额不影响这条记录(约定 S8)。 +func buildUserCouponRow( + c *entity.Coupons, userId int64, grantedBy, remark string, now *gtime.Time, +) g.Map { + return g.Map{ + "coupon_id": c.Id, + "user_id": userId, + "coupon_title": c.Title, + "coupon_type": c.CouponType, + "kind": c.Kind, + "value": c.Value, + "threshold_cents": c.ThresholdCents, + "status": consts.UserCouponStatusUnused, + "source": consts.UserCouponSourceAdmin, + "granted_by": grantedBy, + "granted_at": now, + "expire_at": c.ValidTo, + "remark": remark, + "created_at": now, + "updated_at": now, + } +} + +// couponTemplateScopeOK 券模板适用范围是否覆盖当前订单。 +// 模板缺失时视为「全范围」(fail-open 到「可用」,避免因模板被删而让用户手里的券变砖)。 +func couponTemplateScopeOK(ctx context.Context, couponId int64, orderType int, targetKey string) (bool, error) { + record, err := g.Model(consts.TableCoupons).Where("id", couponId).One() + if err != nil { + return false, err + } + if record.IsEmpty() { + return true, nil + } + return scopeMatches(record["scope"].Int(), parseScopeKeys(record["scope_keys"].String()), orderType, targetKey), nil +} + +// ===== 用户端:/coupon/my(P5)===== + +// CouponMy 我的优惠券(默认全量;status 可筛 0 可用 / 1 已用 / 2 过期)。 +func CouponMy(ctx context.Context, status *int) (*userv1.CouponMyRes, error) { + now := gtime.Now() + endOfMonth := now.EndOfMonth() + // expire_at 的「是否过期 / 是否临期本月」一律在 **SQL 侧**判定(绑定 gtime 参数,与写入同源), + // 规避 gtime 读回恒 Local 造成的 8h 偏移(TZ-01)。 + records, err := g.DB().GetAll(ctx, + "SELECT uc.*, "+ + couponExpirePassedExpr+" AS expire_passed, "+ + "(uc.expire_at IS NOT NULL AND uc.expire_at > ? AND uc.expire_at <= ?) AS expiring_soon "+ + "FROM "+consts.TableUserCoupons+" uc WHERE uc.user_id = ? "+ + "ORDER BY uc.status ASC, uc.expire_at ASC, uc.id DESC", + now, now, endOfMonth, CtxUserId(ctx)) + if err != nil { + return nil, err + } + summary := userv1.CouponMySummary{} + list := make([]userv1.UserCouponItem, 0, len(records)) + for _, r := range records { + c := &entity.UserCoupons{} + if err = r.Struct(c); err != nil { + continue + } + eff := effectiveCouponStatus(c, r["expire_passed"].Bool()) + // 汇总口径 + switch eff { + case consts.UserCouponStatusUnused: + summary.Usable++ + if c.CouponType == consts.CouponTypeFreebie { + summary.Freebie++ + } + if r["expiring_soon"].Bool() { + summary.ExpiringSoon++ + } + case consts.UserCouponStatusUsed: + summary.Used++ + case consts.UserCouponStatusExpired: + summary.Expired++ + } + if status != nil && eff != *status { + continue + } + list = append(list, userv1.UserCouponItem{ + Id: c.Id, + CouponId: c.CouponId, + Title: c.CouponTitle, + SubTitle: couponSubTitleOf(ctx, c.CouponId), + CouponType: c.CouponType, + Kind: c.Kind, + Value: c.Value, + ThresholdCents: c.ThresholdCents, + Status: eff, + ExpireAt: timeStr(c.ExpireAt), + GrantedAt: timeStr(c.GrantedAt), + UsedAt: timeStr(c.UsedAt), + }) + } + return &userv1.CouponMyRes{Summary: summary, List: list}, nil +} + +// couponSubTitleOf 副标题取自模板(模板缺失返回空串)。 +func couponSubTitleOf(ctx context.Context, couponId int64) string { + v, err := g.Model(consts.TableCoupons).Where("id", couponId).Value("sub_title") + if err != nil || v == nil { + return "" + } + return v.String() +} + +// ===== 用户端:/coupon/validate(P6,只读,绝不占用)===== + +// CouponValidate 下单前校验免单券可用性(只读)。 +func CouponValidate(ctx context.Context, userCouponId int64, orderType int, targetKey string) (*userv1.CouponValidateRes, error) { + userId := CtxUserId(ctx) + c, err := userCouponById(ctx, userCouponId) + if err != nil { + return nil, err + } + if c == nil || c.UserId != userId { + return nil, errCouponUnavailable("优惠券不存在") + } + expired, err := couponExpiredAt(ctx, g.Model(consts.TableUserCoupons), c.Id, gtime.Now()) + if err != nil { + return nil, err + } + if err = couponUsableErr(c, expired); err != nil { + return nil, err + } + scopeOK, err := couponTemplateScopeOK(ctx, c.CouponId, orderType, targetKey) + if err != nil { + return nil, err + } + if !scopeOK { + return nil, errCouponUnavailable("该券不适用于当前商品") + } + _, originPrice, err := orderTargetProduct(ctx, orderType, targetKey) + if err != nil { + return nil, err + } + paid := freebiePriceCents(ctx) + if paid <= 0 { + // 服务端自检:免单道具价格异常(不下发用户,日志告警 + 拒单) + g.Log().Errorf(ctx, "[coupon] 免单道具价格自检失败 priceCents=%d productId=%s", paid, freebieProductId(ctx)) + return nil, errProductPriceMismatch("免单配置异常,请稍后再试") + } + return &userv1.CouponValidateRes{ + Valid: true, + CouponType: c.CouponType, + IsFreebie: c.CouponType == consts.CouponTypeFreebie, + ProductId: freebieProductId(ctx), + PriceCents: paid, + OriginPriceCents: originPrice, + PaidPriceCents: paid, + Message: "优惠券可用", + }, nil +} + +// ===== 用户端:/coupon/usable(P7,只返免单券)===== + +// CouponUsable 当前订单可选用的券(**只返免单券**,约定 S7)。 +// +// outTradeNo **可选**(缺省 = 既有行为,完全不破坏其它调用方): +// +// 传入本人订单号后,会读取该订单当前「已选中」的免单券(promo_kind=2 且 user_coupon_id>0)。 +// 该券在下单占用后 status 已变 1(used),会被下面 status=0 的过滤排除 → 用户只有一张券时 +// 列表必然为空(既看不到也换不了,P0 真事故)。故此处**保底**把它补回列表并标记 applied=true, +// 使前端抽屉既能展示「当前已选」打勾,也允许换成别的券 / 取消。 +// +// 语义保证: +// - applied 标记只对「确属本人、且确由本单选中」的那张券为 true; +// - 未传 outTradeNo / 订单不存在 / 非本人 / 未选券 / 选的是优惠码 → 全部 applied=false, +// 结果与旧版逐字节一致(仅多了恒为 false 的 applied 字段)。 +func CouponUsable(ctx context.Context, orderType int, targetKey, outTradeNo string) (*userv1.CouponUsableRes, error) { + userId := CtxUserId(ctx) + now := gtime.Now() + paid := freebiePriceCents(ctx) + // 过期券在 **SQL 侧**过滤(绑定 gtime 参数,与写入同源;TZ-01),读侧不再比较时间。 + records, err := g.Model(consts.TableUserCoupons). + Where("user_id", userId). + Where("coupon_type", consts.CouponTypeFreebie). + Where("status", consts.UserCouponStatusUnused). + Where("(expire_at IS NULL OR expire_at >= ?)", now). + OrderAsc("expire_at").OrderDesc("id"). + All() + if err != nil { + return nil, err + } + + // 当前订单已选中的券 id(无则 0)。仅当传入单号且确为本人订单时才有值。 + appliedId := int64(0) + if strings.TrimSpace(outTradeNo) != "" { + appliedId = appliedCouponIdOfOrder(ctx, userId, outTradeNo) + } + + list := make([]userv1.UsableCouponItem, 0, len(records)+1) + seen := make(map[int64]bool, len(records)) + for _, r := range records { + c := &entity.UserCoupons{} + if err = r.Struct(c); err != nil { + continue + } + scopeOK, err := couponTemplateScopeOK(ctx, c.CouponId, orderType, targetKey) + if err != nil { + continue + } + if !scopeOK { + continue + } + seen[c.Id] = true + list = append(list, userv1.UsableCouponItem{ + UserCouponId: c.Id, + Title: c.CouponTitle, + CouponType: c.CouponType, + IsFreebie: true, + ExpireAt: timeStr(c.ExpireAt), + EstimatedPaidPriceCents: paid, + Applied: c.Id == appliedId, + }) + } + + // 保底:把「已选中但被 status 过滤掉」的那张券补回列表(置顶,标记 applied), + // 使前端抽屉始终能看到并更换当前选中的券(仅有 1 张券时为唯一一条)。 + if appliedId > 0 && !seen[appliedId] { + if c, e := userCouponById(ctx, appliedId); e == nil && c != nil && c.UserId == userId { + list = append([]userv1.UsableCouponItem{{ + UserCouponId: c.Id, + Title: c.CouponTitle, + CouponType: c.CouponType, + IsFreebie: true, + ExpireAt: timeStr(c.ExpireAt), + EstimatedPaidPriceCents: paid, + Applied: true, + }}, list...) + } + } + + return &userv1.CouponUsableRes{List: list}, nil +} + +// appliedCouponIdOfOrder 读取「属于 userId 的 outTradeNo 订单」当前已选中的免单券 id。 +// +// 仅当订单确为本人(Where user_id 一并过滤,防越权枚举)且 promo_kind=2(免单券)时返回其 +// user_coupon_id;其余(订单不存在 / 非本人 / 未选券 / 选的是优惠码)一律返回 0(静默,不报错)。 +func appliedCouponIdOfOrder(ctx context.Context, userId int64, outTradeNo string) int64 { + record, err := g.Model(consts.TableMemberOrders). + Where("out_trade_no", outTradeNo). + Where("user_id", userId). + One() + if err != nil || record.IsEmpty() { + return 0 + } + if record["promo_kind"].Int() != consts.PromoKindCoupon { + return 0 + } + return record["user_coupon_id"].Int64() +} + +// ===== 管理端:券模板(A6–A8)===== + +// AdminCouponTemplateList 券模板列表 +func AdminCouponTemplateList(ctx context.Context, req *adminv1.CouponTemplateListReq) (*adminv1.CouponTemplateListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableCoupons) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + m = m.Where("title LIKE ?", "%"+kw+"%") + } + if req.Status != nil { + m = m.Where("status", *req.Status) + } + if req.CouponType != nil { + m = m.Where("coupon_type", *req.CouponType) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).OrderDesc("id").All() + if err != nil { + return nil, err + } + // 已发 / 已用统计(一次聚合,避免 N+1) + granted, _ := g.Model(consts.TableUserCoupons).Fields("coupon_id, COUNT(*) AS c").Group("coupon_id").All() + used, _ := g.Model(consts.TableUserCoupons). + Fields("coupon_id, COUNT(*) AS c").Where("status", consts.UserCouponStatusUsed).Group("coupon_id").All() + grantedMap := countByInt64(granted, "coupon_id") + usedMap := countByInt64(used, "coupon_id") + + list := make([]adminv1.CouponTemplateItem, 0, len(records)) + for _, r := range records { + id := r["id"].Int64() + list = append(list, adminv1.CouponTemplateItem{ + Id: id, + Title: r["title"].String(), + SubTitle: r["sub_title"].String(), + CouponType: r["coupon_type"].Int(), + Kind: r["kind"].Int(), + Value: r["value"].Int(), + ThresholdCents: r["threshold_cents"].Int(), + Scope: r["scope"].Int(), + ScopeKeys: parseScopeKeys(r["scope_keys"].String()), + TotalLimit: r["total_limit"].Int(), + PerUserLimit: r["per_user_limit"].Int(), + ValidFrom: timeStr(r["valid_from"].GTime()), + ValidTo: timeStr(r["valid_to"].GTime()), + Status: r["status"].Int(), + GrantedCount: grantedMap[id], + UsedCount: usedMap[id], + }) + } + return &adminv1.CouponTemplateListRes{List: list, Total: total}, nil +} + +// AdminCouponTemplateSave 新增/编辑券模板。 +// +// ⚠️ 券型 fail-closed:只有显式传 couponType=1 才是免单券,其余(0/2/未知)一律按展示券 2 处理。 +func AdminCouponTemplateSave(ctx context.Context, req *adminv1.CouponTemplateSaveReq) (*adminv1.CouponTemplateSaveRes, error) { + couponType := consts.CouponTypeDisplay + if req.CouponType == consts.CouponTypeFreebie { + couponType = consts.CouponTypeFreebie + } + kind := req.Kind + if kind != consts.CouponKindCut && kind != consts.CouponKindFreebie { + kind = consts.CouponKindFreebie + } + scopeKeysJSON, _ := json.Marshal(req.ScopeKeys) + validFrom := parseOptionalTime(req.ValidFrom) + validTo := parseOptionalTime(req.ValidTo) + if validFrom != nil && validTo != nil && validTo.Before(validFrom) { + return nil, gerror.New("结束时间不能早于开始时间") + } + data := g.Map{ + "title": strings.TrimSpace(req.Title), + "sub_title": req.SubTitle, + "coupon_type": couponType, + "kind": kind, + "value": req.Value, + "threshold_cents": req.ThresholdCents, + "scope": req.Scope, + "scope_keys": string(scopeKeysJSON), + "total_limit": req.TotalLimit, + "per_user_limit": req.PerUserLimit, + "valid_from": validFrom, + "valid_to": validTo, + "status": req.Status, + "remark": req.Remark, + "updated_at": gtime.Now(), + } + if req.Id > 0 { + exist, err := g.Model(consts.TableCoupons).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("券模板不存在") + } + if _, err = g.Model(consts.TableCoupons).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + return &adminv1.CouponTemplateSaveRes{Id: req.Id}, nil + } + data["created_at"] = gtime.Now() + id, err := g.Model(consts.TableCoupons).Data(data).InsertAndGetId() + if err != nil { + return nil, err + } + return &adminv1.CouponTemplateSaveRes{Id: id}, nil +} + +// AdminCouponTemplateToggle 启停券模板 +func AdminCouponTemplateToggle(ctx context.Context, id int64, status int) (*adminv1.CouponTemplateToggleRes, error) { + if _, err := g.Model(consts.TableCoupons).Where("id", id). + Data(g.Map{"status": status, "updated_at": gtime.Now()}).Update(); err != nil { + return nil, err + } + return &adminv1.CouponTemplateToggleRes{}, nil +} + +// ===== 管理端:发放 / 记录 / 核销(A9–A11)===== + +// AdminCouponGrant 发放优惠券(单用户/批量共用)。 +// +// 返回「部分成功」结果:success 成功条数、failed 失败条数、failures 逐条原因。 +func AdminCouponGrant(ctx context.Context, req *adminv1.CouponGrantReq) (*adminv1.CouponGrantRes, error) { + template, err := couponById(ctx, req.CouponId) + if err != nil { + return nil, err + } + if template == nil { + return nil, gerror.New("券模板不存在") + } + if template.Status != 1 { + return nil, gerror.New("券模板已停用,无法发放") + } + now := gtime.Now() + res := &adminv1.CouponGrantRes{Failures: []adminv1.BatchFailure{}} + + grantedTotal, err := g.Model(consts.TableUserCoupons).Where("coupon_id", template.Id).Count() + if err != nil { + return nil, err + } + operator := CtxAdminAccount(ctx) + + for _, userId := range req.UserIds { + // 总量上限(total_limit 0=不限) + if template.TotalLimit > 0 && grantedTotal >= template.TotalLimit { + res.Failed++ + res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "已达总发行上限"}) + continue + } + // 用户存在性 + u, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One() + if err != nil { + return nil, err + } + if u.IsEmpty() { + res.Failed++ + res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "用户不存在"}) + continue + } + // 单人限领 + if template.PerUserLimit > 0 { + held, err := g.Model(consts.TableUserCoupons). + Where("coupon_id", template.Id).Where("user_id", userId). + WhereNot("status", consts.UserCouponStatusVoid).Count() + if err != nil { + return nil, err + } + if held >= template.PerUserLimit { + res.Failed++ + res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "超出单人限领"}) + continue + } + } + if _, err = g.Model(consts.TableUserCoupons). + Data(buildUserCouponRow(template, userId, operator, req.Remark, now)).Insert(); err != nil { + res.Failed++ + res.Failures = append(res.Failures, adminv1.BatchFailure{Id: userId, Reason: "发放失败"}) + continue + } + res.Success++ + grantedTotal++ + } + return res, nil +} + +// AdminCouponRecordList 发放记录列表(可按用户/券模板/券型/状态筛) +func AdminCouponRecordList(ctx context.Context, req *adminv1.CouponRecordListReq) (*adminv1.CouponRecordListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableUserCoupons) + if req.UserId != nil { + m = m.Where("user_id", *req.UserId) + } + if req.CouponId != nil { + m = m.Where("coupon_id", *req.CouponId) + } + if req.CouponType != nil { + m = m.Where("coupon_type", *req.CouponType) + } + if req.Status != nil { + m = m.Where("status", *req.Status) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).OrderDesc("id").All() + if err != nil { + return nil, err + } + list := make([]adminv1.CouponRecordItem, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.CouponRecordItem{ + Id: r["id"].Int64(), + UserId: r["user_id"].Int64(), + CouponId: r["coupon_id"].Int64(), + Title: r["coupon_title"].String(), + CouponType: r["coupon_type"].Int(), + Kind: r["kind"].Int(), + Value: r["value"].Int(), + Status: r["status"].Int(), + Source: r["source"].Int(), + GrantedAt: timeStr(r["granted_at"].GTime()), + UsedAt: timeStr(r["used_at"].GTime()), + ExpireAt: timeStr(r["expire_at"].GTime()), + UsedBy: r["used_by"].String(), + UsedOrderNo: r["used_order_no"].String(), + }) + } + return &adminv1.CouponRecordListRes{List: list, Total: total}, nil +} + +// AdminCouponRedeem 人工核销。 +// +// 券型隔离(约定 S7):本接口面向展示券;对免单券且 status != 0 返回 4011 +// (免单券的核销应通过下单支付链路完成)。 +func AdminCouponRedeem(ctx context.Context, userCouponId int64, remark string) (*adminv1.CouponRedeemRes, error) { + c, err := userCouponById(ctx, userCouponId) + if err != nil { + return nil, err + } + if c == nil { + return nil, errCouponUnavailable("优惠券不存在") + } + if c.CouponType == consts.CouponTypeFreebie && c.Status != consts.UserCouponStatusUnused { + return nil, errCouponUnavailable(couponStatusReason(c.Status)) + } + if c.Status != consts.UserCouponStatusUnused { + return nil, errCouponUnavailable(couponStatusReason(c.Status)) + } + now := gtime.Now() + if _, err = g.Model(consts.TableUserCoupons).Where("id", userCouponId). + Data(g.Map{ + "status": consts.UserCouponStatusUsed, + "used_at": now, + "used_by": consts.UserCouponUsedByAdmin, + "used_order_no": "", + "remark": remark, + "updated_at": now, + }).Update(); err != nil { + return nil, err + } + return &adminv1.CouponRedeemRes{}, nil +} + +// ===== 下单事务内的免单券原子占用原语(已在 order_pay.go 的 applyVoucherToOrder 内接线)===== + +// occupyFreebie 在事务内锁定并占用一张免单券: +// 行锁 → 校验(券型/状态/有效期)→ 置 status=1 + used_order_no + used_by='system' + lock_at。 +// +// 并发同券下单时(约定 S5/§图②):事务1 先拿到行锁并置 status=1;事务2 阻塞后读到 status=1, +// 校验失败 → 4011;恰好 1 单成功,券只对应 1 个订单号。 +func occupyFreebie( + ctx context.Context, tx gdb.TX, userId, userCouponId int64, + orderType int, targetKey, outTradeNo string, originPriceCents int64, +) (*Voucher, error) { + row, err := tx.Model(consts.TableUserCoupons).Where("id", userCouponId).LockUpdate().One() + if err != nil { + return nil, err + } + if row.IsEmpty() { + return nil, errCouponUnavailable("优惠券不存在") + } + c := &entity.UserCoupons{} + if err = row.Struct(c); err != nil { + return nil, err + } + if c.UserId != userId { + return nil, errCouponUnavailable("优惠券不存在") + } + expired, err := couponExpiredAt(ctx, tx.Model(consts.TableUserCoupons), c.Id, gtime.Now()) + if err != nil { + return nil, err + } + if err = couponUsableErr(c, expired); err != nil { + return nil, err + } + scopeOK, err := couponTemplateScopeOK(ctx, c.CouponId, orderType, targetKey) + if err != nil { + return nil, err + } + if !scopeOK { + return nil, errCouponUnavailable("该券不适用于当前商品") + } + now := gtime.Now() + if _, err = tx.Model(consts.TableUserCoupons).Where("id", userCouponId). + Data(g.Map{ + "status": consts.UserCouponStatusUsed, + "used_at": now, + "used_by": consts.UserCouponUsedBySystem, + "used_order_no": outTradeNo, + "lock_at": now, + "updated_at": now, + }).Update(); err != nil { + return nil, err + } + paid := freebiePriceCents(ctx) + if paid <= 0 { + g.Log().Errorf(ctx, "[coupon] 免单道具价格自检失败 priceCents=%d productId=%s", paid, freebieProductId(ctx)) + return nil, errProductPriceMismatch("免单配置异常,请稍后再试") + } + discount := originPriceCents - paid + if discount < 0 { + discount = 0 + } + return &Voucher{ + ProductId: freebieProductId(ctx), + OriginPriceCents: originPriceCents, + PaidPriceCents: paid, + DiscountCents: discount, + PromoKind: consts.PromoKindCoupon, + UserCouponId: userCouponId, + }, nil +} + +// releaseCouponOnOrder 释放「订单占用」的免单券(换券 / 改回无券 / 关单时调用): +// status 回未使用、清 used_at / used_by / used_order_no / lock_at。 +// +// 守卫:仅当该券确由本单占用(used_order_no 匹配 + used_by='system')才解除, +// 避免误释放「已被人工核销」或「隶属于其他订单」的券。与 occupyFreebie 同处 +// order_pay.go 的 applyVoucherToOrder 事务内调用 → 释放旧 + 占用新原子完成(约定 S5)。 +func releaseCouponOnOrder(ctx context.Context, tx gdb.TX, userCouponId int64, outTradeNo string) error { + if userCouponId <= 0 { + return nil + } + _, err := tx.Model(consts.TableUserCoupons). + Where("id", userCouponId). + Where("used_order_no", outTradeNo). + Where("used_by", consts.UserCouponUsedBySystem). + Data(g.Map{ + "status": consts.UserCouponStatusUnused, + "used_at": nil, + "used_by": "", + "used_order_no": "", + "lock_at": nil, + "updated_at": gtime.Now(), + }).Update() + return err +} + +// ===== 超时释放(gcron,约定 S5)===== + +// ReleaseExpiredCouponLocks 释放在「下单占用」后超时仍未发货的免单券: +// 把 status 回 0、清 used_order_no / used_by / lock_at / used_at,使券重新可用。 +// +// 释放条件:used_by='system'(订单占用而非人工核销)+ lock_at 早于 now - ttl +// + 对应订单不存在或仍为「待支付/已关闭」(未发货、未退款)。 +// ttl 读 settings.coupon.lock.ttlSeconds(默认 900)。 +func ReleaseExpiredCouponLocks(ctx context.Context) { + ttl := couponLockTtlSeconds(ctx) + deadline := gtime.Now().Add(-time.Duration(ttl) * time.Second) + rows, err := g.DB().GetAll(ctx, + "SELECT uc.id AS id, uc.used_order_no AS order_no FROM user_coupons uc "+ + "LEFT JOIN member_orders o ON o.out_trade_no = uc.used_order_no "+ + "WHERE uc.status = ? AND uc.used_by = ? AND uc.lock_at IS NOT NULL AND uc.lock_at < ? "+ + "AND (o.id IS NULL OR o.status IN (?, ?))", + consts.UserCouponStatusUsed, consts.UserCouponUsedBySystem, deadline, + consts.OrderStatusPending, consts.OrderStatusClosed) + if err != nil { + g.Log().Warningf(ctx, "[coupon] 扫描超时占用券失败: %v", err) + return + } + released := 0 + for _, r := range rows { + id := r["id"].Int64() + orderNo := r["order_no"].String() + if _, err = g.Model(consts.TableUserCoupons). + Where("id", id). + Where("status", consts.UserCouponStatusUsed). + Where("used_order_no", orderNo). + Data(g.Map{ + "status": consts.UserCouponStatusUnused, + "used_at": nil, + "used_by": "", + "used_order_no": "", + "lock_at": nil, + "updated_at": gtime.Now(), + }).Update(); err != nil { + g.Log().Warningf(ctx, "[coupon] 释放券失败 id=%d: %v", id, err) + continue + } + released++ + } + if released > 0 { + g.Log().Infof(ctx, "[coupon] 已释放超时占用券 %d 张(ttl=%ds)", released, ttl) + } +} + +// ===== 小工具 ===== + +func userCouponById(ctx context.Context, id int64) (*entity.UserCoupons, error) { + if id <= 0 { + return nil, nil + } + record, err := g.Model(consts.TableUserCoupons).Where("id", id).One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, nil + } + c := &entity.UserCoupons{} + if err = record.Struct(c); err != nil { + return nil, err + } + return c, nil +} + +func couponById(ctx context.Context, id int64) (*entity.Coupons, error) { + if id <= 0 { + return nil, nil + } + record, err := g.Model(consts.TableCoupons).Where("id", id).One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, nil + } + c := &entity.Coupons{} + if err = record.Struct(c); err != nil { + return nil, err + } + return c, nil +} + +// countByInt64 把聚合查询结果转成 map[int64]int64(key 列 → 计数列 c)。 +func countByInt64(records gdb.Result, keyField string) map[int64]int64 { + out := map[int64]int64{} + for _, r := range records { + out[r[keyField].Int64()] = r["c"].Int64() + } + return out +} diff --git a/internal/logic/coupon_test.go b/internal/logic/coupon_test.go new file mode 100644 index 0000000..a0cdccf --- /dev/null +++ b/internal/logic/coupon_test.go @@ -0,0 +1,106 @@ +package logic + +import ( + "testing" + + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 优惠券单测(T04.8 / T06) +// +// 覆盖纯函数:券型隔离(展示券不可用于抵扣)、读取态过期判定、已发券快照隔离。 +// 全部不依赖 DB。 +// +// 注意(T06):expire_at 的「是否过期」已改为 **SQL 侧**判定后以布尔量传入 +// (effectiveCouponStatus / couponUsableErr),故这里直接注入该布尔量,不再依赖 Go 侧时间比较。 +// ============================================================================ + +// TestCouponUsableErr 券型隔离(约定 S7):仅免单券可支付;展示券一律不行(fail-closed)。 +func TestCouponUsableErr(t *testing.T) { + free := func() *entity.UserCoupons { + return &entity.UserCoupons{Id: 1, CouponType: consts.CouponTypeFreebie, Status: consts.UserCouponStatusUnused} + } + cases := []struct { + name string + c *entity.UserCoupons + expired bool + wantCode int // 0 = 通过 + }{ + {"免单券未用未过期-可用", free(), false, 0}, + {"nil-不可用", nil, false, consts.CodeCouponUnavailable}, + {"展示券-不可抵扣", &entity.UserCoupons{CouponType: consts.CouponTypeDisplay, Status: consts.UserCouponStatusUnused}, false, consts.CodeCouponUnavailable}, + {"免单券已用", &entity.UserCoupons{CouponType: consts.CouponTypeFreebie, Status: consts.UserCouponStatusUsed}, false, consts.CodeCouponUnavailable}, + {"免单券已作废", &entity.UserCoupons{CouponType: consts.CouponTypeFreebie, Status: consts.UserCouponStatusVoid}, false, consts.CodeCouponUnavailable}, + {"免单券已过期-状态位", &entity.UserCoupons{CouponType: consts.CouponTypeFreebie, Status: consts.UserCouponStatusExpired}, false, consts.CodeCouponUnavailable}, + {"免单券已过期-SQL 侧判定", &entity.UserCoupons{ + CouponType: consts.CouponTypeFreebie, Status: consts.UserCouponStatusUnused, + ExpireAt: gtime.New("2000-01-01 00:00:00"), + }, true, consts.CodeCouponUnavailable}, + } + for _, c := range cases { + if got := codeOf(couponUsableErr(c.c, c.expired)); got != c.wantCode { + t.Errorf("%s: code=%d, want %d", c.name, got, c.wantCode) + } + } +} + +// TestEffectiveCouponStatus 读取态过期判定:未使用且(SQL 侧判定)已过期 → 视为已过期(不落库)。 +func TestEffectiveCouponStatus(t *testing.T) { + unused := &entity.UserCoupons{Status: consts.UserCouponStatusUnused} + // 未使用 + 已过期 → 2 + if got := effectiveCouponStatus(unused, true); got != consts.UserCouponStatusExpired { + t.Errorf("已过期未使用券应判为 2,got %d", got) + } + // 未使用 + 未过期 → 0 + if got := effectiveCouponStatus(unused, false); got != consts.UserCouponStatusUnused { + t.Errorf("未过期未使用券应判为 0,got %d", got) + } + // 已使用 → 保持 1(不因为过期改判) + used := &entity.UserCoupons{Status: consts.UserCouponStatusUsed} + if got := effectiveCouponStatus(used, true); got != consts.UserCouponStatusUsed { + t.Errorf("已使用券应保持 1,got %d", got) + } +} + +// TestBuildUserCouponRowSnapshot 快照隔离(约定 S8): +// 发放时把模板字段快照进 user_coupons;之后模板改名/改型/改面额**不影响已发券**。 +func TestBuildUserCouponRowSnapshot(t *testing.T) { + now := gtime.Now() + template := &entity.Coupons{ + Id: 10, Title: "免单券A", CouponType: consts.CouponTypeFreebie, + Kind: consts.CouponKindFreebie, Value: 500, ThresholdCents: 100, + ValidTo: gtime.New("2999-01-01 00:00:00"), + } + row := buildUserCouponRow(template, 7, "admin", "新客礼", now) + + // 发放后立刻改动模板 + template.Title = "改名后" + template.CouponType = consts.CouponTypeDisplay + template.Value = 9999 + + if row["coupon_title"] != "免单券A" { + t.Errorf("标题快照应保持不变,got %v", row["coupon_title"]) + } + if row["coupon_type"] != consts.CouponTypeFreebie { + t.Errorf("券型快照应保持不变,got %v", row["coupon_type"]) + } + if row["value"] != 500 { + t.Errorf("面额快照应保持不变,got %v", row["value"]) + } + if row["user_id"] != int64(7) { + t.Errorf("user_id 错误,got %v", row["user_id"]) + } + if row["status"] != consts.UserCouponStatusUnused { + t.Errorf("新发券状态应为未使用 0,got %v", row["status"]) + } + if row["source"] != consts.UserCouponSourceAdmin { + t.Errorf("来源应为后台发放,got %v", row["source"]) + } + if row["granted_by"] != "admin" { + t.Errorf("发放人错误,got %v", row["granted_by"]) + } +} diff --git a/internal/logic/event_code.go b/internal/logic/event_code.go new file mode 100644 index 0000000..f100616 --- /dev/null +++ b/internal/logic/event_code.go @@ -0,0 +1,109 @@ +package logic + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 年会域:活动小程序码(wxacode.getUnlimited) +// 依据:PRD-02 R7-11/R7-24;架构 §5.2 A18、§8.3。 +// +// 约定: +// - scene **只承载 event_id**(不含任何个人信息); +// - access_token 复用 xpay.go 的 wxAccessToken(),不重造; +// - 未配置 wx.appId / wx.appSecret → 返回明确中文提示,不报错不 panic。 +// ============================================================================ + +// qrCodePage 小程序码落地页(扫码后进入的查座页分包页面)。 +// 属需环境化的集成参数:上线前需确认该页面已在现网发布。 +const qrCodePage = "pkg-event/query" + +// QrCodeDir 码图落盘目录(相对服务运行目录)。 +// +// ⚠️ 导出给 cmd 包使用,不要改回私有常量:该目录此前只在「生成码图」时惰性创建 +// (见 AdminEventQrcode 里的 gfile.Mkdir),而 cmd.go 的静态路由 AddStaticPath +// 要求目录**在注册时必须已存在**,否则直接 FATAL 退出且不降级 —— +// 于是出现「服务起不来 → 生不了码图 → 目录建不出来」的先有鸡先有蛋死锁。 +// 现由 cmd.go 启动时预创建(gfile.Mkdir = MkdirAll,已存在则返回 nil)。 +const QrCodeDir = "manifest/qrcode" + +// AdminEventQrcode 生成活动小程序码:调用微信 getUnlimited → 落盘 → 记录 code_url。 +func AdminEventQrcode(ctx context.Context, req *v1.EventQrcodeReq) (*v1.EventQrcodeRes, error) { + event, err := eventRow(ctx, req.EventId) + if err != nil { + return nil, err + } + + cfg := xpayConfig(ctx) + if cfg.AppId == "" || cfg.Secret == "" { + return nil, gerror.New("未配置 wx.appId / wx.appSecret,无法生成小程序码;请在该环境 config.yaml 补全后重试") + } + token, err := wxAccessToken(ctx) + if err != nil { + // 把底层错误转成人话,不把原始英文/错误码直接抛给用户 + return nil, gerror.New("获取微信 access_token 失败,请稍后重试或检查 wx.appId / wx.appSecret 配置") + } + + // scene 只承载 event_id(getUnlimited 的 scene 有长度上限,event_id 远不触顶) + scene := strconv.FormatInt(event.Id, 10) + body := g.Map{ + "scene": scene, + "page": qrCodePage, + "check_path": false, // 联调期页面可能未发布,避免因校验路径失败 + "env_version": "release", + } + resp, err := g.Client().ContentJson().Post(ctx, xpayAPIBase+"/wxa/getwxacodeunlimit?access_token="+token, body) + if err != nil { + return nil, gerror.Newf("调用 wxacode.getUnlimited 失败: %v", err) + } + defer resp.Close() + data := resp.ReadAll() + if len(data) == 0 { + return nil, gerror.New("生成小程序码失败:微信返回空响应") + } + // 成功返回 PNG 二进制;失败返回 JSON(含 errcode/errmsg) + if isJsonBytes(data) { + var we struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + _ = json.Unmarshal(data, &we) + return nil, gerror.Newf("生成小程序码失败:%d %s", we.ErrCode, we.ErrMsg) + } + + if err = gfile.Mkdir(QrCodeDir); err != nil { + return nil, gerror.Newf("创建码图目录失败: %v", err) + } + fileName := "event_" + scene + "_" + gtime.Now().Format("YmdHis") + ".png" + path := gfile.Join(QrCodeDir, fileName) + if err = gfile.PutBytes(path, data); err != nil { + return nil, gerror.Newf("写入码图失败: %v", err) + } + + // 对外可访问地址(由 cmd.go 的静态路由 /qrcode 提供) + codeUrl := "/qrcode/" + fileName + if _, err = g.Model(consts.TableAnnualEvents).Where("id", event.Id).Data(g.Map{ + "code_url": codeUrl, + "updated_at": gtime.Now(), + }).Update(); err != nil { + return nil, err + } + return &v1.EventQrcodeRes{CodeUrl: codeUrl}, nil +} + +// isJsonBytes 判断响应是否为 JSON(微信失败时返回 JSON,成功返回 PNG)。 +func isJsonBytes(b []byte) bool { + t := strings.TrimSpace(string(b)) + return strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") +} diff --git a/internal/logic/event_enterprise.go b/internal/logic/event_enterprise.go new file mode 100644 index 0000000..734c684 --- /dev/null +++ b/internal/logic/event_enterprise.go @@ -0,0 +1,330 @@ +package logic + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 年会域:企业 / 员工 / 候场名单 +// 依据:PRD-02 §4.2、§6.2;架构 §3.2.2-3.2.5、§5.2 A1-A5。 +// +// 信息架构(PRD-02 §4.2.1): +// Enterprise 1 ── n Employee(企业级员工库,跨活动复用) +// Event n ── n Employee(关联表 event_participants = 候场名单) +// ============================================================================ + +// ===== 企业 ===== + +// AdminEnterpriseList 企业列表(含员工数) +func AdminEnterpriseList(ctx context.Context, req *v1.EnterpriseListReq) (*v1.EnterpriseListRes, error) { + model := g.Model(consts.TableEnterprises) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + model = model.WhereLike("name", "%"+kw+"%") + } + total, err := model.Count() + if err != nil { + return nil, err + } + page, size := normalizeAdminPage(req.Page, req.PageSize) + records, err := model.OrderDesc("id").Page(page, size).All() + if err != nil { + return nil, err + } + + list := make([]v1.EnterpriseItem, 0, len(records)) + for _, r := range records { + ent := &entity.Enterprises{} + if err = r.Struct(ent); err != nil { + continue + } + empCount, err := g.Model(consts.TableEmployees).Where("enterprise_id", ent.Id).WhereNull("deleted_at").Count() + if err != nil { + return nil, err + } + list = append(list, v1.EnterpriseItem{ + Id: ent.Id, + Name: ent.Name, + Contact: ent.Contact, + Remark: ent.Remark, + Status: ent.Status, + EmpCount: int64(empCount), + }) + } + return &v1.EnterpriseListRes{List: list, Total: total}, nil +} + +// AdminEnterpriseSave 建/改企业;status 缺省视为启用。 +func AdminEnterpriseSave(ctx context.Context, req *v1.EnterpriseSaveReq) (*v1.EnterpriseSaveRes, error) { + now := gtime.Now() + status := req.Status + if status != 0 && status != 1 { + status = 1 + } + if req.Id > 0 { + result, err := g.Model(consts.TableEnterprises).Where("id", req.Id).Data(g.Map{ + "name": req.Name, + "contact": req.Contact, + "remark": req.Remark, + "status": status, + "updated_at": now, + }).Update() + if err != nil { + return nil, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + // 无字段变化时 RowsAffected 也可能为 0,回查确认存在性 + exists, err := g.Model(consts.TableEnterprises).Where("id", req.Id).Count() + if err != nil { + return nil, err + } + if exists == 0 { + return nil, gerror.New("企业不存在") + } + } + return &v1.EnterpriseSaveRes{Id: req.Id}, nil + } + + id, err := g.Model(consts.TableEnterprises).Data(g.Map{ + "name": req.Name, + "contact": req.Contact, + "remark": req.Remark, + "status": status, + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + return nil, err + } + return &v1.EnterpriseSaveRes{Id: id}, nil +} + +// ===== 员工 ===== + +// AdminEmployeeList 员工库列表;EventId>0 时标记 InEvent(是否已在候场名单)。 +func AdminEmployeeList(ctx context.Context, req *v1.EmployeeListReq) (*v1.EmployeeListRes, error) { + // 软删除:员工列表默认排除已删员工 + model := g.Model(consts.TableEmployees).WhereNull("deleted_at") + if req.EnterpriseId > 0 { + model = model.Where("enterprise_id", req.EnterpriseId) + } + if kw := strings.TrimSpace(req.Keyword); kw != "" { + like := "%" + kw + "%" + model = model.Where("(name LIKE ? OR phone LIKE ?)", like, like) + } + total, err := model.Count() + if err != nil { + return nil, err + } + page, size := normalizeAdminPage(req.Page, req.PageSize) + records, err := model.OrderDesc("id").Page(page, size).All() + if err != nil { + return nil, err + } + + inEvent := map[int64]bool{} + excludeAssign := map[int64]int{} + if req.EventId > 0 { + parts, err := g.Model(consts.TableEventParticipants).Where("event_id", req.EventId).All() + if err != nil { + return nil, err + } + for _, p := range parts { + empId := p["employee_id"].Int64() + inEvent[empId] = true + excludeAssign[empId] = p["exclude_assign"].Int() + } + } + + list := make([]v1.EmployeeItem, 0, len(records)) + for _, r := range records { + emp := &entity.Employees{} + if err = r.Struct(emp); err != nil { + continue + } + list = append(list, v1.EmployeeItem{ + Id: emp.Id, + Name: emp.Name, + Phone: emp.Phone, + Dept: emp.Dept, + Remark: emp.Remark, + Status: emp.Status, + InEvent: inEvent[emp.Id], + ExcludeAssign: excludeAssign[emp.Id], + }) + } + return &v1.EmployeeListRes{List: list, Total: total}, nil +} + +// AdminParticipantExclude 批量设置员工「不参与排座」(N4)。仅更新已在候场名单(in_event)的员工。 +func AdminParticipantExclude(ctx context.Context, req *v1.ParticipantExcludeReq) (*v1.ParticipantExcludeRes, error) { + if _, err := eventRow(ctx, req.EventId); err != nil { + return nil, err + } + now := gtime.Now() + model := g.Model(consts.TableEventParticipants). + Where("event_id", req.EventId). + WhereIn("employee_id", req.EmployeeIds) + if _, err := g.Model(consts.TableEventParticipants). + Where("event_id", req.EventId). + WhereIn("employee_id", req.EmployeeIds). + Data(g.Map{"exclude_assign": req.ExcludeAssign, "updated_at": now}). + Update(); err != nil { + return nil, err + } + // 值未变化时 UPDATE 的 RowsAffected 可能为 0(默认非 CLIENT_FOUND_ROWS),故以 COUNT 作为「实际生效」口径 + updated, err := model.Count() + if err != nil { + return nil, err + } + // T17 审计:批量改「不参与排座」属座次配置变更 + WriteAudit(ctx, AuditEntry{ + Action: "event.participant_exclude", + TargetType: "event", + TargetId: auditId(req.EventId), + After: g.Map{"employee_ids": req.EmployeeIds, "exclude_assign": req.ExcludeAssign, "updated": updated}, + Result: consts.AuditResultSuccess, + }) + return &v1.ParticipantExcludeRes{Success: int(updated)}, nil +} + +// AdminEmployeeSave 新增/修改员工(手工加人);EventId>0 时同时纳入候场名单。 +// +// 去重键 (enterprise_id, phone):命中已存在记录时返回明确提示,避免直接抛 500。 +func AdminEmployeeSave(ctx context.Context, req *v1.EmployeeSaveReq) (*v1.EmployeeSaveRes, error) { + if exists, err := g.Model(consts.TableEnterprises).Where("id", req.EnterpriseId).Count(); err != nil { + return nil, err + } else if exists == 0 { + return nil, gerror.New("企业不存在") + } + phone := normalizePhone(req.Phone) + now := gtime.Now() + + var employeeId int64 + if req.Id > 0 { + result, err := g.Model(consts.TableEmployees). + Where("id", req.Id). + Where("enterprise_id", req.EnterpriseId). + WhereNull("deleted_at"). + Data(g.Map{ + "name": req.Name, + "phone": phone, + "dept": req.Dept, + "remark": req.Remark, + "updated_at": now, + }).Update() + if err != nil { + return nil, friendlyDbErr(err) + } + if affected, _ := result.RowsAffected(); affected == 0 { + if exists, err := g.Model(consts.TableEmployees).Where("id", req.Id).Where("enterprise_id", req.EnterpriseId).WhereNull("deleted_at").Count(); err != nil { + return nil, err + } else if exists == 0 { + return nil, gerror.New("员工不存在") + } + } + employeeId = req.Id + } else { + id, err := g.Model(consts.TableEmployees).Data(g.Map{ + "enterprise_id": req.EnterpriseId, + "name": req.Name, + "phone": phone, + "dept": req.Dept, + "remark": req.Remark, + "status": 1, + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + return nil, friendlyDbErr(err) + } + employeeId = id + } + + if req.EventId > 0 { + if err := addParticipant(ctx, req.EventId, employeeId); err != nil { + return nil, err + } + } + return &v1.EmployeeSaveRes{Id: employeeId}, nil +} + +// AdminEmployeeStatus 员工启停 +func AdminEmployeeStatus(ctx context.Context, req *v1.EmployeeStatusReq) (*v1.EmployeeStatusRes, error) { + result, err := g.Model(consts.TableEmployees).Where("id", req.Id).WhereNull("deleted_at").Data(g.Map{ + "status": req.Status, + "updated_at": gtime.Now(), + }).Update() + if err != nil { + return nil, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + if exists, err := g.Model(consts.TableEmployees).Where("id", req.Id).WhereNull("deleted_at").Count(); err != nil { + return nil, err + } else if exists == 0 { + return nil, gerror.New("员工不存在") + } + } + return &v1.EmployeeStatusRes{}, nil +} + +// ===== 候场名单 ===== + +// addParticipant 幂等把员工纳入活动候场名单(依赖 UNIQUE uk_event_emp)。 +func addParticipant(ctx context.Context, eventId, employeeId int64) error { + exists, err := g.Model(consts.TableEventParticipants). + Where("event_id", eventId).Where("employee_id", employeeId).Count() + if err != nil { + return err + } + if exists > 0 { + return nil + } + if _, err = g.Model(consts.TableEventParticipants).Data(g.Map{ + "event_id": eventId, + "employee_id": employeeId, + "created_at": gtime.Now(), + }).Insert(); err != nil { + // 并发下可能已被插入,忽略唯一键冲突 + return nil + } + return nil +} + +// ===== 辅助 ===== + +// normalizePhone 入库前统一去空格(PRD-02 §4.2.3「手机号去空格」)。 +func normalizePhone(phone string) string { + return strings.ReplaceAll(strings.TrimSpace(phone), " ", "") +} + +// friendlyDbErr 把唯一键冲突等 DB 错误转为可读提示,避免 500。 +func friendlyDbErr(err error) error { + msg := err.Error() + if strings.Contains(msg, "Duplicate") || strings.Contains(msg, "uk_ent_phone") || strings.Contains(msg, "1062") { + return gerror.New("该手机号在本企业下已存在") + } + return err +} + +// normalizeAdminPage 管理端分页兜底:page≥1,1≤pageSize≤200。 +func normalizeAdminPage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size <= 0 { + size = 20 + } + if size > 200 { + size = 200 + } + return page, size +} diff --git a/internal/logic/event_exclude_test.go b/internal/logic/event_exclude_test.go new file mode 100644 index 0000000..10a9195 --- /dev/null +++ b/internal/logic/event_exclude_test.go @@ -0,0 +1,112 @@ +package logic + +import ( + "strings" + "testing" + + "github.com/gogf/gf/v2/errors/gerror" + + "tool-api/internal/consts" +) + +// ============================================================================ +// T04 自动排座「排除」语义单测(纯函数,不依赖 DB) +// +// 覆盖: +// - 排除桌上的座位/人员完全不被触碰(不分配、不清手工号、不计 manual_overridden); +// - fail-closed:全部桌被排除 → 4018 且 message 列出被排除的桌; +// - 部分排除 / 无桌 → 放行。 +// ============================================================================ + +// TestPlanAutoAssignExcludesTable 排除桌的座位不参与分配,且不计入 manual_overridden。 +func TestPlanAutoAssignExcludesTable(t *testing.T) { + seats := []seatBrief{ + // 1 号桌:参与 + {Id: 1, TableId: 1, SeatNo: 1, EmployeeId: 0}, + {Id: 2, TableId: 1, SeatNo: 2, IsManual: true, EmployeeId: 0}, // 参与桌的手工位:reorder 可用 + // 2 号桌:被排除(不参与自动排序) + {Id: 3, TableId: 2, SeatNo: 1, IsManual: true, EmployeeId: 0, Excluded: true}, // 排除桌的手工空位 + {Id: 4, TableId: 2, SeatNo: 2, EmployeeId: 0, Excluded: true}, // 排除桌的普通空位 + } + assigns, remaining, overridden := planAutoAssign(seats, []int64{101, 102, 103}, consts.SeatModeReorder) + + // 排除桌 id=3、id=4 一律不可被占用 + for _, a := range assigns { + if a.SeatId == 3 || a.SeatId == 4 { + t.Fatalf("排除桌的座位不应被分配,got %v", assigns) + } + } + // 参与桌有 2 个可用位(id=1、id=2),故只分配 2 人,剩 1 人 + if len(assigns) != 2 { + t.Fatalf("应分配 2 人(仅参与桌),got %d (%v)", len(assigns), assigns) + } + if len(remaining) != 1 || remaining[0] != 103 { + t.Fatalf("remaining=%v want [103]", remaining) + } + // manual_overridden 只统计**参与桌**的手工位(id=2),排除桌的手工位(id=3)不计入 + if overridden != 1 { + t.Fatalf("排除桌的手工位不应计入 manual_overridden,got %d want 1", overridden) + } +} + +// TestPlanAutoAssignExcludesTableFill 排除桌在 fill 模式下同样不被触碰。 +func TestPlanAutoAssignExcludesTableFill(t *testing.T) { + seats := []seatBrief{ + {Id: 1, TableId: 1, SeatNo: 1, EmployeeId: 0}, + {Id: 2, TableId: 2, SeatNo: 1, EmployeeId: 0, Excluded: true}, + } + assigns, _, _ := planAutoAssign(seats, []int64{101, 102}, consts.SeatModeFill) + if len(assigns) != 1 || assigns[0].SeatId != 1 { + t.Fatalf("fill 应只填参与桌 id=1,got %v", assigns) + } +} + +// TestPlanAutoGuardsAllExcluded 全部桌被排除 → 4018,且 message 列出被排除的桌。 +func TestPlanAutoGuardsAllExcluded(t *testing.T) { + tables := []tableBrief{ + {Id: 1, Name: "主桌", ExcludeAuto: 1}, + {Id: 2, TableNo: "2", ExcludeAuto: 1}, + } + err := planAutoGuards(tables) + if err == nil { + t.Fatalf("全部桌被排除应报错(fail-closed)") + } + if code := gerror.Code(err).Code(); code != consts.CodeSeatAutoInvalid { + t.Fatalf("错误码应为 %d,got %d", consts.CodeSeatAutoInvalid, code) + } + msg := err.Error() + if !strings.Contains(msg, "主桌") || !strings.Contains(msg, "2 号桌") { + t.Fatalf("错误信息应列出被排除的桌,got: %s", msg) + } +} + +// TestPlanAutoGuardsPartialExcluded 部分桌可参与 → 放行(含仅 1 张桌可参与)。 +func TestPlanAutoGuardsPartialExcluded(t *testing.T) { + tables := []tableBrief{ + {Id: 1, Name: "签到台", ExcludeAuto: 1}, + {Id: 2, Name: "1 号桌", ExcludeAuto: 0}, // 仅此 1 张可参与 → 允许 + } + if err := planAutoGuards(tables); err != nil { + t.Fatalf("有 1 张可参与桌时应放行,got %v", err) + } +} + +// TestPlanAutoGuardsNoTables 无桌 → 放行(交由后续逻辑自然得到 0 结果,保持老行为)。 +func TestPlanAutoGuardsNoTables(t *testing.T) { + if err := planAutoGuards(nil); err != nil { + t.Fatalf("无桌不应触发 fail-closed,got %v", err) + } +} + +// TestTableDisplayName 桌名回退:Name > 桌号 > id。 +func TestTableDisplayName(t *testing.T) { + if got := tableDisplayName(tableBrief{Id: 1, Name: "主桌", TableNo: "1"}); got != "主桌" { + t.Fatalf("应优先用 Name,got %q", got) + } + if got := tableDisplayName(tableBrief{Id: 2, TableNo: "3"}); got != "3 号桌" { + t.Fatalf("无 Name 时用「桌号 号桌」,got %q", got) + } + if got := tableDisplayName(tableBrief{Id: 7}); got != "桌#7" { + t.Fatalf("无桌名桌号时回退 id,got %q", got) + } +} diff --git a/internal/logic/event_import.go b/internal/logic/event_import.go new file mode 100644 index 0000000..5ac3a89 --- /dev/null +++ b/internal/logic/event_import.go @@ -0,0 +1,556 @@ +package logic + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 年会域:员工导入(两段式) +// 依据:PRD-02 R7-3/R7-4/R7-5、§5.2 异常流;架构 §3.2.8、§4.3 图③、§8.4。 +// +// 约定: +// - 管理端(SheetJS)负责解析文件,后端只收结构化行数组,零新增依赖; +// - 预览只写 event_import_logs(status=draft) 快照,**不落 employees**; +// - 防篡改采用 **DB 快照 by id**(非 HMAC):确认时凭 import_log_id 从库读回快照, +// 只接受快照内的行,无需密钥,规避硬编码密钥隐患; +// - 未裁决冲突 → 4008;确认入库全程单事务,含 event_participants UPSERT。 +// ============================================================================ + +// gcodeImportConflict 导入存在未处理冲突(4008) +func gcodeImportConflict() gcode.Code { + return gcode.New(consts.CodeImportConflict, "", nil) +} + +// ============================================================================ +// 纯类型与纯函数(不碰 DB,单测直接覆盖) +// ============================================================================ + +// importRow 一行导入数据(来自管理端解析) +type importRow struct { + Name string + Phone string + Dept string + Remark string +} + +// existingEmployee 现有员工快照(分类用) +type existingEmployee struct { + Id int64 + Name string + Phone string +} + +// classifiedRow 分类后的行(会序列化进快照) +type classifiedRow struct { + Idx int `json:"idx"` + Type string `json:"type"` // new / update / conflict / error + Name string `json:"name"` + Phone string `json:"phone"` + Dept string `json:"dept"` + Remark string `json:"remark"` + Message string `json:"message"` + MatchedEmployeeId int64 `json:"matched_employee_id"` // update:同号命中;conflict:同名命中 +} + +// importSnapshot 预览快照(落 event_import_logs.snapshot) +type importSnapshot struct { + EnterpriseId int64 `json:"enterprise_id"` + EventId int64 `json:"event_id"` + Rows []classifiedRow `json:"rows"` +} + +// importOp 一条待执行操作 +type importOp struct { + Kind string // insert / update / skip + RowIdx int + Name string + Phone string + Dept string + Remark string + UpdateId int64 +} + +// classifyImportRows 逐行分类(R7-4/R7-5): +// +// 手机号缺失/格式非法 → error +// 手机号命中现有员工 → update(视为同一人,更新姓名/部门/备注) +// 姓名命中现有员工(手机号不同) → conflict(疑似换了手机号,需人工裁决) +// 其余 → new +func classifyImportRows(rows []importRow, existing []existingEmployee) []classifiedRow { + byPhone := make(map[string]existingEmployee, len(existing)) + byName := make(map[string][]existingEmployee) + for _, e := range existing { + byPhone[e.Phone] = e + byName[e.Name] = append(byName[e.Name], e) + } + + out := make([]classifiedRow, 0, len(rows)) + for i, r := range rows { + name := strings.TrimSpace(r.Name) + phone := normalizePhone(r.Phone) + c := classifiedRow{Idx: i, Name: name, Phone: phone, Dept: r.Dept, Remark: r.Remark} + switch { + case phone == "": + c.Type = consts.ImportTypeError + c.Message = "手机号缺失" + case !validPhone(phone): + c.Type = consts.ImportTypeError + c.Message = "手机号格式不正确" + default: + if e, ok := byPhone[phone]; ok { + c.Type = consts.ImportTypeUpdate + c.MatchedEmployeeId = e.Id + c.Message = "同手机号,更新" + } else if list := byName[name]; len(list) > 0 { + c.Type = consts.ImportTypeConflict + c.MatchedEmployeeId = list[0].Id + c.Message = "同名不同号,疑似换了手机号" + } else { + c.Type = consts.ImportTypeNew + c.Message = "将新增" + } + } + out = append(out, c) + } + return out +} + +// planImportOps 结合人工裁决生成待执行操作。 +// 返回 unresolved = 仍未裁决(缺少合法裁决)的 conflict 行数;>0 时确认接口应返回 4008。 +func planImportOps(rows []classifiedRow, decisions map[int]string) (ops []importOp, unresolved int) { + ops = make([]importOp, 0, len(rows)) + for _, c := range rows { + switch c.Type { + case consts.ImportTypeError: + ops = append(ops, importOp{Kind: "skip", RowIdx: c.Idx}) + case consts.ImportTypeNew: + ops = append(ops, importOp{Kind: "insert", RowIdx: c.Idx, Name: c.Name, Phone: c.Phone, Dept: c.Dept, Remark: c.Remark}) + case consts.ImportTypeUpdate: + ops = append(ops, importOp{Kind: "update", RowIdx: c.Idx, UpdateId: c.MatchedEmployeeId, Name: c.Name, Phone: c.Phone, Dept: c.Dept, Remark: c.Remark}) + case consts.ImportTypeConflict: + switch decisions[c.Idx] { + case consts.ImportActionMerge: + // 视为同一人·更新手机号 → 更新现有员工(含手机号) + ops = append(ops, importOp{Kind: "update", RowIdx: c.Idx, UpdateId: c.MatchedEmployeeId, Name: c.Name, Phone: c.Phone, Dept: c.Dept, Remark: c.Remark}) + case consts.ImportActionCreate: + // 视为另一个人新增(手机号不同,不违反 uk_ent_phone) + ops = append(ops, importOp{Kind: "insert", RowIdx: c.Idx, Name: c.Name, Phone: c.Phone, Dept: c.Dept, Remark: c.Remark}) + case consts.ImportActionIgnore: + ops = append(ops, importOp{Kind: "skip", RowIdx: c.Idx}) + default: + unresolved++ + } + } + } + return ops, unresolved +} + +// validPhone 基础校验:仅数字,长度 6..20。 +func validPhone(phone string) bool { + if len(phone) < 6 || len(phone) > 20 { + return false + } + for _, r := range phone { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// ============================================================================ +// 预览 +// ============================================================================ + +// AdminImportPreview 导入预览:分类 + 写 draft 快照;不落 employees。 +func AdminImportPreview(ctx context.Context, req *v1.ImportPreviewReq) (*v1.ImportPreviewRes, error) { + limit := importRowLimit(ctx) + if len(req.Rows) > limit { + return nil, gerror.New(fmt.Sprintf("单次导入最多 %d 行,请拆分后再试", limit)) + } + if exists, err := g.Model(consts.TableEnterprises).Where("id", req.EnterpriseId).Count(); err != nil { + return nil, err + } else if exists == 0 { + return nil, gerror.New("企业不存在") + } + if req.EventId > 0 { + if exists, err := g.Model(consts.TableAnnualEvents).Where("id", req.EventId).Count(); err != nil { + return nil, err + } else if exists == 0 { + return nil, gerror.New("活动不存在") + } + } + + existing, err := loadExistingEmployees(ctx, req.EnterpriseId) + if err != nil { + return nil, err + } + rows := make([]importRow, 0, len(req.Rows)) + for _, r := range req.Rows { + rows = append(rows, importRow{Name: r.Name, Phone: r.Phone, Dept: r.Dept, Remark: r.Remark}) + } + classified := classifyImportRows(rows, existing) + + snapBytes, err := json.Marshal(importSnapshot{ + EnterpriseId: req.EnterpriseId, + EventId: req.EventId, + Rows: classified, + }) + if err != nil { + return nil, err + } + + counts := countClassified(classified) + now := gtime.Now() + logId, err := g.Model(consts.TableEventImportLogs).Data(g.Map{ + "enterprise_id": req.EnterpriseId, + "event_id": req.EventId, + "file_name": req.FileName, + "status": consts.ImportStatusDraft, + "token": "", + "snapshot": string(snapBytes), + "total": len(classified), + "inserted": counts.inserted, + "updated": counts.updated, + "skipped": counts.skipped, + "conflict": counts.conflict, + "conflicts": "", + "operator": adminOperator(ctx), + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + return nil, err + } + + rowsOut := make([]v1.ImportPreviewRowOut, 0, len(classified)) + for _, c := range classified { + rowsOut = append(rowsOut, v1.ImportPreviewRowOut{ + Idx: c.Idx, + Type: c.Type, + Name: c.Name, + Phone: c.Phone, + Dept: c.Dept, + Message: c.Message, + }) + } + return &v1.ImportPreviewRes{ + ImportLogId: logId, + Total: len(classified), + Inserted: counts.inserted, + Updated: counts.updated, + Skipped: counts.skipped, + Conflict: counts.conflict, + Rows: rowsOut, + }, nil +} + +// ============================================================================ +// 确认入库(单事务) +// ============================================================================ + +// AdminImportConfirm 确认导入:读回快照 → 校验裁决完整性 → 单事务写 employees + UPSERT 候场名单 + 置 committed。 +func AdminImportConfirm(ctx context.Context, req *v1.ImportConfirmReq) (*v1.ImportConfirmRes, error) { + logRec, err := g.Model(consts.TableEventImportLogs).Where("id", req.ImportLogId).One() + if err != nil { + return nil, err + } + if logRec.IsEmpty() { + return nil, gerror.New("导入记录不存在") + } + if logRec["status"].String() != consts.ImportStatusDraft { + return nil, gerror.New("该导入已处理,请重新预览") + } + snapshot := &importSnapshot{} + if err = json.Unmarshal([]byte(logRec["snapshot"].String()), snapshot); err != nil { + return nil, gerror.New("导入快照损坏,请重新预览") + } + + // 只允许裁决快照中的 conflict 行 + conflictIdx := make(map[int]bool) + for _, c := range snapshot.Rows { + if c.Type == consts.ImportTypeConflict { + conflictIdx[c.Idx] = true + } + } + decisions := make(map[int]string, len(req.Decisions)) + for _, d := range req.Decisions { + if !conflictIdx[d.Idx] { + return nil, gerror.NewCode(gcode.CodeValidationFailed, "裁决行索引非法") + } + decisions[d.Idx] = d.Action + } + + ops, unresolved := planImportOps(snapshot.Rows, decisions) + if unresolved > 0 { + return nil, gerror.NewCode(gcodeImportConflict(), "存在未处理的导入冲突") + } + + var ( + inserted int + updated int + skipped int + ) + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + inserted, updated, skipped = 0, 0, 0 + now := gtime.Now() + for _, op := range ops { + switch op.Kind { + case "skip": + skipped++ + case "insert": + empId, isInserted, e := insertEmployeeTx(ctx, tx, snapshot.EnterpriseId, op, now) + if e != nil { + return e + } + if isInserted { + inserted++ + } else { + updated++ // 并发/重导命中唯一键 → 转更新 + } + if snapshot.EventId > 0 { + if e = addParticipantTx(ctx, tx, snapshot.EventId, empId, now); e != nil { + return e + } + } + case "update": + if _, e := tx.Model(consts.TableEmployees).Where("id", op.UpdateId).Data(g.Map{ + "name": op.Name, + "phone": op.Phone, + "dept": op.Dept, + "remark": op.Remark, + "updated_at": now, + }).Update(); e != nil { + return e + } + updated++ + if snapshot.EventId > 0 { + if e := addParticipantTx(ctx, tx, snapshot.EventId, op.UpdateId, now); e != nil { + return e + } + } + } + } + + conflictsJson, _ := json.Marshal(decisions) + if _, e := tx.Model(consts.TableEventImportLogs).Where("id", req.ImportLogId).Data(g.Map{ + "status": consts.ImportStatusCommitted, + "inserted": inserted, + "updated": updated, + "skipped": skipped, + "conflict": len(conflictIdx), + "conflicts": string(conflictsJson), + "updated_at": now, + }).Update(); e != nil { + return e + } + return nil + }) + if err != nil { + return nil, err + } + // T17 审计:确认导入属敏感操作 + WriteAudit(ctx, AuditEntry{ + Action: "event.import_confirm", + TargetType: "import_log", + TargetId: auditId(req.ImportLogId), + After: g.Map{ + "enterprise_id": snapshot.EnterpriseId, + "event_id": snapshot.EventId, + "inserted": inserted, + "updated": updated, + "skipped": skipped, + }, + Result: consts.AuditResultSuccess, + }) + return &v1.ImportConfirmRes{Inserted: inserted, Updated: updated, Skipped: skipped}, nil +} + +// AdminImportLogList 导入历史 +func AdminImportLogList(ctx context.Context, req *v1.ImportLogListReq) (*v1.ImportLogListRes, error) { + model := g.Model(consts.TableEventImportLogs) + if req.EnterpriseId > 0 { + model = model.Where("enterprise_id", req.EnterpriseId) + } + if req.EventId > 0 { + model = model.Where("event_id", req.EventId) + } + total, err := model.Count() + if err != nil { + return nil, err + } + page, size := normalizeAdminPage(req.Page, req.PageSize) + records, err := model.OrderDesc("id").Page(page, size).All() + if err != nil { + return nil, err + } + list := make([]v1.ImportLogItem, 0, len(records)) + for _, r := range records { + log := &entity.EventImportLogs{} + if err = r.Struct(log); err != nil { + continue + } + list = append(list, v1.ImportLogItem{ + Id: log.Id, + EnterpriseId: log.EnterpriseId, + EventId: log.EventId, + FileName: log.FileName, + Status: log.Status, + Total: log.Total, + Inserted: log.Inserted, + Updated: log.Updated, + Skipped: log.Skipped, + Conflict: log.Conflict, + Operator: log.Operator, + CreatedAt: formatGTime(log.CreatedAt), + }) + } + return &v1.ImportLogListRes{List: list, Total: total}, nil +} + +// ============================================================================ +// 内部辅助 +// ============================================================================ + +type importCounts struct { + inserted int + updated int + skipped int + conflict int +} + +// countClassified 预览口径计数:new→inserted,update→updated,error→skipped,conflict→conflict。 +func countClassified(rows []classifiedRow) importCounts { + c := importCounts{} + for _, r := range rows { + switch r.Type { + case consts.ImportTypeNew: + c.inserted++ + case consts.ImportTypeUpdate: + c.updated++ + case consts.ImportTypeError: + c.skipped++ + case consts.ImportTypeConflict: + c.conflict++ + } + } + return c +} + +// loadExistingEmployees 读取企业现有员工(分类用) +func loadExistingEmployees(ctx context.Context, enterpriseId int64) ([]existingEmployee, error) { + // 软删除:已删员工不参与「同号/同名」分类,重新导入同一手机号视为新增 + records, err := g.Model(consts.TableEmployees).Where("enterprise_id", enterpriseId).WhereNull("deleted_at").All() + if err != nil { + return nil, err + } + out := make([]existingEmployee, 0, len(records)) + for _, r := range records { + out = append(out, existingEmployee{ + Id: r["id"].Int64(), + Name: r["name"].String(), + Phone: r["phone"].String(), + }) + } + return out, nil +} + +// insertEmployeeTx 事务内插入员工;命中唯一键(并发/重导)则转更新,保证 uk_ent_phone 不产生重复。 +func insertEmployeeTx(ctx context.Context, tx gdb.TX, enterpriseId int64, op importOp, now *gtime.Time) (int64, bool, error) { + id, err := tx.Model(consts.TableEmployees).Data(g.Map{ + "enterprise_id": enterpriseId, + "name": op.Name, + "phone": op.Phone, + "dept": op.Dept, + "remark": op.Remark, + "status": 1, + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + if isDuplicateErr(err) { + rec, e := tx.Model(consts.TableEmployees). + Where("enterprise_id", enterpriseId).Where("phone", op.Phone).One() + if e != nil { + return 0, false, e + } + if rec.IsEmpty() { + return 0, false, err + } + existingId := rec["id"].Int64() + if _, e = tx.Model(consts.TableEmployees).Where("id", existingId).Data(g.Map{ + "name": op.Name, + "dept": op.Dept, + "remark": op.Remark, + "updated_at": now, + }).Update(); e != nil { + return 0, false, e + } + return existingId, false, nil + } + return 0, false, err + } + return id, true, nil +} + +// addParticipantTx 事务内幂等纳入候场名单(UPSERT 语义,靠 UNIQUE uk_event_emp 避免重复挂名单)。 +func addParticipantTx(ctx context.Context, tx gdb.TX, eventId, employeeId int64, now *gtime.Time) error { + exists, err := tx.Model(consts.TableEventParticipants). + Where("event_id", eventId).Where("employee_id", employeeId).Count() + if err != nil { + return err + } + if exists > 0 { + return nil + } + if _, err = tx.Model(consts.TableEventParticipants).Data(g.Map{ + "event_id": eventId, + "employee_id": employeeId, + "created_at": now, + }).Insert(); err != nil { + // 并发下可能已被插入(uk_event_emp):忽略,保持幂等 + return nil + } + return nil +} + +// isDuplicateErr 判断是否 MySQL 唯一键冲突(1062)。 +func isDuplicateErr(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "Duplicate") || strings.Contains(msg, "1062") || strings.Contains(msg, "uk_ent_phone") +} + +// adminOperator 记录操作人(管理端 id),无上下文时为空串。 +func adminOperator(ctx context.Context) string { + id := CtxAdminId(ctx) + if id <= 0 { + return "" + } + return strconv.FormatInt(id, 10) +} + +// importRowLimit 单次导入行数上限:settings 覆盖 > 默认 2000。 +func importRowLimit(ctx context.Context) int { + raw := SettingValue(ctx, consts.SettingImportRowLimit) + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + return n + } + return consts.DefaultImportRowLimit +} diff --git a/internal/logic/event_import_test.go b/internal/logic/event_import_test.go new file mode 100644 index 0000000..7f6d4a4 --- /dev/null +++ b/internal/logic/event_import_test.go @@ -0,0 +1,149 @@ +package logic + +import ( + "testing" + + "tool-api/internal/consts" +) + +// TestClassifyImportRows 覆盖判据:同号同名→update、同名异号→conflict、缺号/格式错→error、新号→new。 +func TestClassifyImportRows(t *testing.T) { + existing := []existingEmployee{ + {Id: 1, Name: "李四", Phone: "13900000002"}, + {Id: 2, Name: "王五", Phone: "13700000003"}, + } + rows := []importRow{ + {Name: "张三", Phone: "13800000001"}, // new + {Name: "李四", Phone: "13900000002"}, // update(同手机号) + {Name: "王五", Phone: "18800000009"}, // conflict(同名不同号) + {Name: "赵六", Phone: ""}, // error(缺号) + {Name: "钱七", Phone: "abc123"}, // error(格式非法) + } + got := classifyImportRows(rows, existing) + want := []string{ + consts.ImportTypeNew, + consts.ImportTypeUpdate, + consts.ImportTypeConflict, + consts.ImportTypeError, + consts.ImportTypeError, + } + if len(got) != len(want) { + t.Fatalf("len=%d want %d", len(got), len(want)) + } + for i := range want { + if got[i].Type != want[i] { + t.Fatalf("row %d type=%s want %s", i, got[i].Type, want[i]) + } + } + if got[1].MatchedEmployeeId != 1 { + t.Fatalf("update 应命中现有员工 id=1,got %d", got[1].MatchedEmployeeId) + } + if got[2].MatchedEmployeeId != 2 { + t.Fatalf("conflict 应命中同名员工 id=2,got %d", got[2].MatchedEmployeeId) + } +} + +// TestClassifyImportRowsPhoneNormalized 手机号去空格后再判重。 +func TestClassifyImportRowsPhoneNormalized(t *testing.T) { + existing := []existingEmployee{{Id: 7, Name: "李四", Phone: "13900000002"}} + got := classifyImportRows([]importRow{{Name: "李四", Phone: " 139 0000 0002 "}}, existing) + if got[0].Type != consts.ImportTypeUpdate || got[0].MatchedEmployeeId != 7 { + t.Fatalf("去空格后应命中同一人,got type=%s id=%d", got[0].Type, got[0].MatchedEmployeeId) + } +} + +// TestPlanImportOpsUnresolvedConflict 未裁决冲突 → unresolved>0(确认接口据此返回 4008)。 +func TestPlanImportOpsUnresolvedConflict(t *testing.T) { + rows := []classifiedRow{ + {Idx: 0, Type: consts.ImportTypeConflict, Name: "王五", Phone: "18800000009", MatchedEmployeeId: 2}, + {Idx: 1, Type: consts.ImportTypeNew, Name: "张三", Phone: "13800000001"}, + {Idx: 2, Type: consts.ImportTypeError, Name: "赵六", Phone: ""}, + {Idx: 3, Type: consts.ImportTypeUpdate, Name: "李四", Phone: "13900000002", MatchedEmployeeId: 1}, + } + ops, unresolved := planImportOps(rows, nil) + if unresolved != 1 { + t.Fatalf("未裁决冲突计数=%d want 1", unresolved) + } + for _, op := range ops { + if op.RowIdx == 0 { + t.Fatalf("未裁决的冲突行不应产生操作,got %v", op) + } + } +} + +// TestPlanImportOpsMergeCreateIgnore 覆盖三种裁决各自的结果。 +func TestPlanImportOpsMergeCreateIgnore(t *testing.T) { + base := []classifiedRow{ + {Idx: 0, Type: consts.ImportTypeConflict, Name: "王五", Phone: "18800000009", MatchedEmployeeId: 2}, + } + + // merge → update 现有员工(含更新手机号) + ops, unresolved := planImportOps(base, map[int]string{0: consts.ImportActionMerge}) + if unresolved != 0 || len(ops) != 1 { + t.Fatalf("merge: unresolved=%d ops=%d", unresolved, len(ops)) + } + if ops[0].Kind != "update" || ops[0].UpdateId != 2 || ops[0].Phone != "18800000009" { + t.Fatalf("merge 应为 update 更新 id=2 且手机号为新号,got %+v", ops[0]) + } + + // create → insert(手机号不同,不违反 uk_ent_phone) + ops, unresolved = planImportOps(base, map[int]string{0: consts.ImportActionCreate}) + if unresolved != 0 || len(ops) != 1 || ops[0].Kind != "insert" { + t.Fatalf("create 应为 insert,got unresolved=%d ops=%+v", unresolved, ops) + } + + // ignore → skip + ops, unresolved = planImportOps(base, map[int]string{0: consts.ImportActionIgnore}) + if unresolved != 0 || len(ops) != 1 || ops[0].Kind != "skip" { + t.Fatalf("ignore 应为 skip,got unresolved=%d ops=%+v", unresolved, ops) + } + + // 非法动作 → 视为未裁决 + _, unresolved = planImportOps(base, map[int]string{0: "whatever"}) + if unresolved != 1 { + t.Fatalf("非法裁决动作应按未裁决处理,got unresolved=%d", unresolved) + } +} + +// TestReimportSameFileAllUpdate 覆盖判据④:重复导入同一文件 → 全部为 update(不重复新增)。 +func TestReimportSameFileAllUpdate(t *testing.T) { + rows := []importRow{ + {Name: "张三", Phone: "13800000001"}, + {Name: "李四", Phone: "13900000002"}, + } + + // 首次导入:库中无人 → 全部 new + first := classifyImportRows(rows, nil) + for _, c := range first { + if c.Type != consts.ImportTypeNew { + t.Fatalf("首次导入应为 new,got %s", c.Type) + } + } + + // 再次导入同一文件:库中已有这两人 → 全部 update + existing := []existingEmployee{ + {Id: 1, Name: "张三", Phone: "13800000001"}, + {Id: 2, Name: "李四", Phone: "13900000002"}, + } + second := classifyImportRows(rows, existing) + for _, c := range second { + if c.Type != consts.ImportTypeUpdate { + t.Fatalf("重复导入应全部为 update,got %s(row %d)", c.Type, c.Idx) + } + } +} + +// TestCountClassified 预览计数口径。 +func TestCountClassified(t *testing.T) { + rows := []classifiedRow{ + {Type: consts.ImportTypeNew}, + {Type: consts.ImportTypeNew}, + {Type: consts.ImportTypeUpdate}, + {Type: consts.ImportTypeError}, + {Type: consts.ImportTypeConflict}, + } + c := countClassified(rows) + if c.inserted != 2 || c.updated != 1 || c.skipped != 1 || c.conflict != 1 { + t.Fatalf("counts=%+v want inserted2/updated1/skipped1/conflict1", c) + } +} diff --git a/internal/logic/event_layout.go b/internal/logic/event_layout.go new file mode 100644 index 0000000..ad344ca --- /dev/null +++ b/internal/logic/event_layout.go @@ -0,0 +1,1097 @@ +package logic + +import ( + "context" + "sort" + "strconv" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 年会域:活动 / 布局 / 桌 / 座 / 自动排座 +// 依据:PRD-02 §4.2.1-4.2.2、§5.3;架构 §4.2、§5.2 A9-A17、§8.6.5。 +// +// 关键约定: +// - 桌与座必须一致:建桌即生成 1..capacity 座;改 capacity 联动补/删座; +// - 座位号默认 1..capacity(顺时针自桌正上方 12 点起);is_manual 标记人工改号; +// - 布局并发编辑用 CAS(layout_version);冲突返回 code=4009; +// - 自动排座 fill 保留手工号 / reorder 覆盖手工号(§4.2.2)。 +// ============================================================================ + +// seatBrief 座位规划用的轻量快照(纯函数入参,便于单测) +type seatBrief struct { + Id int64 + TableId int64 + SeatNo int + IsManual bool + EmployeeId int64 // 0 = 空位 + Excluded bool // true = 所属桌「不参与自动排序」(该桌座位与人员完全不被触碰) +} + +// seatAssign 一条待写入的座位绑定 +type seatAssign struct { + SeatId int64 + EmployeeId int64 +} + +// tableBrief 桌的「排除校验」用轻量快照(纯函数入参,便于单测) +type tableBrief struct { + Id int64 + TableNo string + Name string + ExcludeAuto int +} + +// tableDisplayName 桌的可读名(用于错误提示列出被排除的桌) +func tableDisplayName(t tableBrief) string { + if t.Name != "" { + return t.Name + } + if t.TableNo != "" { + return t.TableNo + " 号桌" + } + return "桌#" + strconv.FormatInt(t.Id, 10) +} + +// planAutoGuards 自动排座的前置校验(纯函数,单测直接覆盖)。 +// +// fail-closed(D4):**全部桌被排除**(无 exclude_auto=0 的桌)→ 返回 4018, +// message 列出被排除的桌,不静默「排成功 0 人」。仅 1 张桌可参与时**允许**执行(提示由前端据本地桌数据给出)。 +func planAutoGuards(tables []tableBrief) error { + if len(tables) == 0 { + return nil // 无桌:交由后续逻辑自然得到 0 结果(保持老行为) + } + participants := 0 + excluded := make([]string, 0) + for _, t := range tables { + if t.ExcludeAuto == 1 { + excluded = append(excluded, tableDisplayName(t)) + } else { + participants++ + } + } + if participants == 0 { + return gerror.NewCode(gcode.New(consts.CodeSeatAutoInvalid, "", nil), + "没有可参与自动排序的桌(已排除:"+strings.Join(excluded, "、")+");请先取消部分桌的「不参与自动排序」") + } + return nil +} + +// gcodeLayoutConflict 布局版本冲突(4009) +func gcodeLayoutConflict() gcode.Code { + return gcode.New(consts.CodeLayoutConflict, "", nil) +} + +// ============================================================================ +// 纯规划函数(不碰 DB,单测直接覆盖) +// ============================================================================ + +// planSeatNumbers 生成默认座位号 1..capacity。 +func planSeatNumbers(capacity int) []int { + if capacity < 0 { + capacity = 0 + } + out := make([]int, 0, capacity) + for n := 1; n <= capacity; n++ { + out = append(out, n) + } + return out +} + +// planCapacityChange 计算 capacity 变更需要补出的座位号与需要移除的座位。 +// - toAddNos:1..newCapacity 中当前不存在的号(升序); +// - toRemove:seat_no > newCapacity 的现有座位(其 EmployeeId 会被调用方计入「未分配」)。 +func planCapacityChange(seats []seatBrief, newCapacity int) (toAddNos []int, toRemove []seatBrief) { + if newCapacity < 0 { + newCapacity = 0 + } + existing := make(map[int]bool, len(seats)) + for _, s := range seats { + existing[s.SeatNo] = true + } + toAddNos = make([]int, 0) + for n := 1; n <= newCapacity; n++ { + if !existing[n] { + toAddNos = append(toAddNos, n) + } + } + toRemove = make([]seatBrief, 0) + for _, s := range seats { + if s.SeatNo > newCapacity { + toRemove = append(toRemove, s) + } + } + sort.SliceStable(toRemove, func(i, j int) bool { return toRemove[i].SeatNo < toRemove[j].SeatNo }) + return toAddNos, toRemove +} + +// planAutoAssign 计算自动排座结果。 +// +// mode=fill :候选 = 非手工且空位的座位(跳过 is_manual=1),编号不动;manualOverridden=0; +// mode=reorder :先视为清空全部手工标记,候选 = 全部空位座位;manualOverridden=手工座位数。 +// +// **排除桌(s.Excluded=true)**:其座位**完全不被触碰** —— 不分配人、不重编座号、 +// 不清其手工标记、也不计入 manualOverridden(N4 / S14)。已坐人的座位一律保持不动; +// 未分配员工按 (tableId, seatNo) 升序依次填位。 +func planAutoAssign(seats []seatBrief, unassignedEmp []int64, mode string) (assigns []seatAssign, remaining []int64, manualOverridden int) { + ordered := make([]seatBrief, len(seats)) + copy(ordered, seats) + sort.SliceStable(ordered, func(i, j int) bool { + if ordered[i].TableId != ordered[j].TableId { + return ordered[i].TableId < ordered[j].TableId + } + return ordered[i].SeatNo < ordered[j].SeatNo + }) + + candidates := make([]seatBrief, 0, len(ordered)) + for _, s := range ordered { + if s.Excluded { + continue // 排除桌:座位与人员完全不被触碰 + } + if mode == consts.SeatModeReorder && s.IsManual { + manualOverridden++ + } + if s.EmployeeId != 0 { + continue // 已坐人,保持不动 + } + if mode == consts.SeatModeFill && s.IsManual { + continue // fill 跳过手工座位(保留其编号与空位状态) + } + candidates = append(candidates, s) + } + + assigns = make([]seatAssign, 0, len(candidates)) + remaining = make([]int64, 0) + for i, emp := range unassignedEmp { + if i < len(candidates) { + assigns = append(assigns, seatAssign{SeatId: candidates[i].Id, EmployeeId: emp}) + } else { + remaining = append(remaining, emp) + } + } + return assigns, remaining, manualOverridden +} + +// ============================================================================ +// 活动 +// ============================================================================ + +// eventRow 读取活动,不存在报错。 +func eventRow(ctx context.Context, eventId int64) (*entity.AnnualEvents, error) { + record, err := g.Model(consts.TableAnnualEvents).Where("id", eventId).One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, gerror.New("活动不存在") + } + event := &entity.AnnualEvents{} + if err = record.Struct(event); err != nil { + return nil, err + } + return event, nil +} + +// AdminEventList 活动列表 +func AdminEventList(ctx context.Context, req *v1.EventListReq) (*v1.EventListRes, error) { + model := g.Model(consts.TableAnnualEvents) + if req.EnterpriseId > 0 { + model = model.Where("enterprise_id", req.EnterpriseId) + } + total, err := model.Count() + if err != nil { + return nil, err + } + page, size := normalizeAdminPage(req.Page, req.PageSize) + records, err := model.OrderDesc("id").Page(page, size).All() + if err != nil { + return nil, err + } + + list := make([]v1.EventItem, 0, len(records)) + for _, r := range records { + event := &entity.AnnualEvents{} + if err = r.Struct(event); err != nil { + continue + } + name := "" + if ent, err := g.Model(consts.TableEnterprises).Where("id", event.EnterpriseId).One(); err == nil && !ent.IsEmpty() { + name = ent["name"].String() + } + tableCount, err := g.Model(consts.TableEventTables).Where("event_id", event.Id).Count() + if err != nil { + return nil, err + } + list = append(list, v1.EventItem{ + Id: event.Id, + EnterpriseId: event.EnterpriseId, + EnterpriseName: name, + Title: event.Title, + Venue: event.Venue, + EventTime: formatGTime(event.EventTime), + Status: event.Status, + LayoutVersion: event.LayoutVersion, + CodeUrl: event.CodeUrl, + TableCount: int64(tableCount), + }) + } + return &v1.EventListRes{List: list, Total: total}, nil +} + +// AdminEventSave 建/改活动。新建默认 status=draft、layout_version=0。 +func AdminEventSave(ctx context.Context, req *v1.EventSaveReq) (*v1.EventSaveRes, error) { + if exists, err := g.Model(consts.TableEnterprises).Where("id", req.EnterpriseId).Count(); err != nil { + return nil, err + } else if exists == 0 { + return nil, gerror.New("企业不存在") + } + eventTime, err := parseEventTime(req.EventTime) + if err != nil { + return nil, err + } + now := gtime.Now() + if req.Id > 0 { + result, err := g.Model(consts.TableAnnualEvents).Where("id", req.Id).Data(g.Map{ + "enterprise_id": req.EnterpriseId, + "title": req.Title, + "venue": req.Venue, + "event_time": eventTime, + "updated_at": now, + }).Update() + if err != nil { + return nil, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + if exists, err := g.Model(consts.TableAnnualEvents).Where("id", req.Id).Count(); err != nil { + return nil, err + } else if exists == 0 { + return nil, gerror.New("活动不存在") + } + } + return &v1.EventSaveRes{Id: req.Id}, nil + } + + id, err := g.Model(consts.TableAnnualEvents).Data(g.Map{ + "enterprise_id": req.EnterpriseId, + "title": req.Title, + "venue": req.Venue, + "event_time": eventTime, + "status": consts.EventStatusDraft, + "hall_layout": "", + "code_url": "", + "layout_version": 0, + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + return nil, err + } + return &v1.EventSaveRes{Id: id}, nil +} + +// AdminEventStatus 发布/结束/回草稿 +func AdminEventStatus(ctx context.Context, req *v1.EventStatusReq) (*v1.EventStatusRes, error) { + before, err := eventRow(ctx, req.EventId) + if err != nil { + return nil, err + } + if _, err := g.Model(consts.TableAnnualEvents).Where("id", req.EventId).Data(g.Map{ + "status": req.Status, + "updated_at": gtime.Now(), + }).Update(); err != nil { + return nil, err + } + // T17 审计:发布/结束活动属敏感操作 + WriteAudit(ctx, AuditEntry{ + Action: "event.status", + TargetType: "event", + TargetId: auditId(req.EventId), + Before: g.Map{"status": before.Status}, + After: g.Map{"status": req.Status}, + Result: consts.AuditResultSuccess, + }) + return &v1.EventStatusRes{}, nil +} + +// AdminEventDetail 设计器全量:活动 + 桌(含座)+ 候场数 + 未分配池。 +func AdminEventDetail(ctx context.Context, req *v1.EventDetailReq) (*v1.EventDetailRes, error) { + event, err := eventRow(ctx, req.EventId) + if err != nil { + return nil, err + } + tables, err := loadTables(ctx, req.EventId) + if err != nil { + return nil, err + } + seats, err := loadSeats(ctx, req.EventId) + if err != nil { + return nil, err + } + nameMap, err := loadEmployeeNames(ctx, collectEmployeeIds(seats)) + if err != nil { + return nil, err + } + + seatsByTable := map[int64][]v1.SeatOut{} + seatCount := 0 + for _, s := range seats { + seatCount++ + empId := int64(0) + if s.EmployeeId != nil { + empId = *s.EmployeeId + } + seatsByTable[s.TableId] = append(seatsByTable[s.TableId], v1.SeatOut{ + Id: s.Id, + SeatNo: s.SeatNo, + IsManual: s.IsManual == 1, + EmployeeId: empId, + EmployeeName: nameMap[empId], + }) + } + + tableOuts := make([]v1.TableOut, 0, len(tables)) + for _, t := range tables { + tableOuts = append(tableOuts, v1.TableOut{ + Id: t.Id, + TableNo: t.TableNo, + Name: t.Name, + X: t.X, + Y: t.Y, + Capacity: t.Capacity, + Rotation: t.Rotation, + TableType: t.TableType, + Remark: t.Remark, + ExcludeAuto: t.ExcludeAuto, + Seats: seatsByTable[t.Id], + }) + } + + participantCount, err := g.Model(consts.TableEventParticipants).Where("event_id", req.EventId).Count() + if err != nil { + return nil, err + } + unassigned, err := unassignedParticipants(ctx, req.EventId) + if err != nil { + return nil, err + } + + return &v1.EventDetailRes{ + Id: event.Id, + EnterpriseId: event.EnterpriseId, + Title: event.Title, + Venue: event.Venue, + EventTime: formatGTime(event.EventTime), + Status: event.Status, + LayoutVersion: event.LayoutVersion, + HallLayout: event.HallLayout, + Tables: tableOuts, + ParticipantCount: participantCount, + SeatCount: seatCount, + Unassigned: unassigned, + }, nil +} + +// ============================================================================ +// 布局保存(CAS) +// ============================================================================ + +// AdminEventLayoutSave 保存大厅布局:CAS 更新 layout_version,冲突返回 4009;同时 upsert 桌。 +func AdminEventLayoutSave(ctx context.Context, req *v1.EventLayoutSaveReq) (*v1.EventLayoutSaveRes, error) { + event, err := eventRow(ctx, req.EventId) // 先确保活动存在 + if err != nil { + return nil, err + } + if req.LayoutVersion < 0 { + return nil, gerror.NewCode(gcode.CodeValidationFailed, "缺少布局版本号") + } + + newVersion := req.LayoutVersion + 1 + now := gtime.Now() + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + result, err := tx.Model(consts.TableAnnualEvents). + Where("id", req.EventId). + Where("layout_version", req.LayoutVersion). + Data(g.Map{ + "hall_layout": req.HallLayout, + "layout_version": gdb.Raw("layout_version + 1"), + "updated_at": now, + }).Update() + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.NewCode(gcodeLayoutConflict(), + "布局已被他人修改(当前版本 "+itoa(event.LayoutVersion)+"),请刷新后重试") + } + for _, tbl := range req.Tables { + if _, _, _, _, err = saveTableRow(ctx, tx, req.EventId, tbl); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &v1.EventLayoutSaveRes{LayoutVersion: newVersion}, nil +} + +// ============================================================================ +// 桌(含座位联动) +// ============================================================================ + +// saveTableRow 事务内 upsert 一张桌并同步座位。 +// 返回:桌 id、补座数、删座数、因减座移入未分配的人数。 +func saveTableRow(ctx context.Context, tx gdb.TX, eventId int64, in v1.TableIn) (tableId int64, added, removed, moved int, err error) { + now := gtime.Now() + capacity := in.Capacity + + // 新建 + if in.Id == 0 { + if capacity < 0 { + capacity = 0 // 特殊桌(签到台/媒体席/备用桌)允许 0 座 + } + tableId, err = tx.Model(consts.TableEventTables).Data(g.Map{ + "event_id": eventId, + "table_no": in.TableNo, + "name": in.Name, + "x": in.X, + "y": in.Y, + "capacity": capacity, + "rotation": in.Rotation, + "table_type": in.TableType, + "remark": in.Remark, + "exclude_auto": in.ExcludeAuto, + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + return 0, 0, 0, 0, err + } + for _, no := range planSeatNumbers(capacity) { + if _, err = tx.Model(consts.TableEventSeats).Data(g.Map{ + "event_id": eventId, + "table_id": tableId, + "seat_no": no, + "is_manual": 0, + "employee_id": nil, + "created_at": now, + "updated_at": now, + }).Insert(); err != nil { + return 0, 0, 0, 0, err + } + } + return tableId, capacity, 0, 0, nil + } + + // 更新:行锁读取现有桌 + tableId = in.Id + record, err := tx.Model(consts.TableEventTables). + Where("id", in.Id).Where("event_id", eventId).LockUpdate().One() + if err != nil { + return 0, 0, 0, 0, err + } + if record.IsEmpty() { + return 0, 0, 0, 0, gerror.New("桌不存在") + } + oldCapacity := record["capacity"].Int() + if capacity < 0 { + capacity = oldCapacity + } + + // 现有座位快照 + briefs, err := loadSeatBriefs(ctx, tx, eventId, tableId) + if err != nil { + return 0, 0, 0, 0, err + } + toAddNos, toRemove := planCapacityChange(briefs, capacity) + + // 减座:删除多余座位;其上员工自动回到「未分配」池 + for _, s := range toRemove { + if s.EmployeeId != 0 { + moved++ + } + if _, err = tx.Model(consts.TableEventSeats).Where("id", s.Id).Delete(); err != nil { + return 0, 0, 0, 0, err + } + } + // 增座:补出空位 + for _, no := range toAddNos { + if _, err = tx.Model(consts.TableEventSeats).Data(g.Map{ + "event_id": eventId, + "table_id": tableId, + "seat_no": no, + "is_manual": 0, + "employee_id": nil, + "created_at": now, + "updated_at": now, + }).Insert(); err != nil { + return 0, 0, 0, 0, err + } + } + // 更新桌 + if _, err = tx.Model(consts.TableEventTables).Where("id", tableId).Data(g.Map{ + "table_no": in.TableNo, + "name": in.Name, + "x": in.X, + "y": in.Y, + "capacity": capacity, + "rotation": in.Rotation, + "table_type": in.TableType, + "remark": in.Remark, + "exclude_auto": in.ExcludeAuto, + "updated_at": now, + }).Update(); err != nil { + return 0, 0, 0, 0, err + } + return tableId, len(toAddNos), len(toRemove), moved, nil +} + +// AdminTableSave 建/改桌(改人数联动补/删座)。 +func AdminTableSave(ctx context.Context, req *v1.TableSaveReq) (*v1.TableSaveRes, error) { + if _, err := eventRow(ctx, req.EventId); err != nil { + return nil, err + } + var ( + tableId int64 + added int + removed int + moved int + ) + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + var e error + tableId, added, removed, moved, e = saveTableRow(ctx, tx, req.EventId, v1.TableIn{ + Id: req.Id, + TableNo: req.TableNo, + Name: req.Name, + X: req.X, + Y: req.Y, + Capacity: req.Capacity, + Rotation: req.Rotation, + TableType: req.TableType, + Remark: req.Remark, + ExcludeAuto: req.ExcludeAuto, + }) + return e + }) + if err != nil { + return nil, err + } + return &v1.TableSaveRes{ + TableId: tableId, + AddedSeats: added, + RemovedSeats: removed, + MovedToUnassigned: moved, + }, nil +} + +// AdminTableDelete 删桌(连同座位;其上员工回到未分配池)。 +func AdminTableDelete(ctx context.Context, req *v1.TableDeleteReq) (*v1.TableDeleteRes, error) { + if _, err := eventRow(ctx, req.EventId); err != nil { + return nil, err + } + moved := 0 + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + occupied, err := tx.Model(consts.TableEventSeats). + Where("event_id", req.EventId).Where("table_id", req.TableId). + WhereNotNull("employee_id").Count() + if err != nil { + return err + } + moved = occupied + if _, err = tx.Model(consts.TableEventSeats). + Where("event_id", req.EventId).Where("table_id", req.TableId).Delete(); err != nil { + return err + } + result, err := tx.Model(consts.TableEventTables). + Where("id", req.TableId).Where("event_id", req.EventId).Delete() + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("桌不存在") + } + return nil + }) + if err != nil { + return nil, err + } + // T17 审计:删桌属敏感(会移动已绑定员工) + WriteAudit(ctx, AuditEntry{ + Action: "event.table_delete", + TargetType: "table", + TargetId: auditId(req.TableId), + After: g.Map{"event_id": req.EventId, "moved_to_unassigned": moved}, + Result: consts.AuditResultSuccess, + }) + return &v1.TableDeleteRes{MovedToUnassigned: moved}, nil +} + +// ============================================================================ +// 座位:改号(对调语义)/ 绑定 / 解绑 +// ============================================================================ + +// seatSwapPlan 座位对调计划(纯计算结果) +type seatSwapPlan struct { + SeatAId int64 // 被改号的座位 + SeatANewNo int // 目标号 + SeatBId int64 // 原占目标号的座位(0 = 无需对调,幂等) + SeatBNewNo int // B 的新号(= A 的原号) + TempNo int // 交换过程中 B 的中转号(避开 uk_evt_tbl_seat) +} + +// planSeatSwap 计算把 seatAId 的座位号改为 targetNo 的**对调**计划。 +// +// 语义(用户拍板 = 对调编号): +// - 目标号必须已在同一桌存在 —— 对调两者编号,而非新增编号; +// - targetNo 不存在于该桌(含超出 1..capacity)→ 报错「该桌没有 N 号座位」; +// - targetNo == 当前号 → 幂等(SeatBId=0)。 +// +// 注意:对调仅交换「编号标签」,座位行与其上员工绑定均不动(员工不随号迁移)。 +func planSeatSwap(seats []seatBrief, seatAId int64, targetNo int) (*seatSwapPlan, error) { + var cur *seatBrief + for i := range seats { + if seats[i].Id == seatAId { + cur = &seats[i] + break + } + } + if cur == nil { + return nil, gerror.New("座位不存在") + } + if targetNo <= 0 { + return nil, gerror.New("座位号非法") + } + if targetNo == cur.SeatNo { + return &seatSwapPlan{SeatAId: seatAId, SeatANewNo: targetNo}, nil + } + var target *seatBrief + for i := range seats { + if seats[i].SeatNo == targetNo { + target = &seats[i] + break + } + } + if target == nil { + return nil, gerror.Newf("该桌没有 %d 号座位", targetNo) + } + return &seatSwapPlan{ + SeatAId: cur.Id, + SeatANewNo: targetNo, + SeatBId: target.Id, + SeatBNewNo: cur.SeatNo, + TempNo: maxSeatNo(seats) + 100000, + }, nil +} + +// maxSeatNo 桌上最大座位号(用于生成安全的中转号)。 +func maxSeatNo(seats []seatBrief) int { + m := 0 + for _, s := range seats { + if s.SeatNo > m { + m = s.SeatNo + } + } + return m +} + +// AdminSeatSave 改座位号(**对调语义**)/ 绑定 / 解绑。 +// +// 改号:把座位 A(当前 cur)改为 N 时,与同桌上 seat_no=N 的座位 B **对调编号** +// (A→N、B→cur),二者 is_manual 均置 1(后续 fill 会跳过它们,不被自动排座抹掉)。 +// **员工跟随座位行不动**(只换编号标签):例如原「3 号·张三」「8 号·李四」对调后 +// 变为「8 号·张三」「3 号·李四」,两人都没离开自己的物理位置 —— 这决定了查座页显示什么。 +func AdminSeatSave(ctx context.Context, req *v1.SeatSaveReq) (*v1.SeatSaveRes, error) { + record, err := g.Model(consts.TableEventSeats). + Where("id", req.SeatId).Where("event_id", req.EventId).One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, gerror.New("座位不存在") + } + tableId := record["table_id"].Int64() + curSeatNo := record["seat_no"].Int() + + bindChanged := false + var bindValue interface{} // nil=解绑;非 nil=绑定 + if req.EmployeeId != nil { + bindChanged = true + if *req.EmployeeId == 0 { + bindValue = nil + } else { + bindValue = *req.EmployeeId + } + } + + renumber := req.SeatNo > 0 && req.SeatNo != curSeatNo + + // 仅改绑定(或幂等无操作) + if !renumber { + if bindChanged { + if _, err = g.Model(consts.TableEventSeats).Where("id", req.SeatId).Data(g.Map{ + "employee_id": bindValue, + "updated_at": gtime.Now(), + }).Update(); err != nil { + return nil, err + } + } + return &v1.SeatSaveRes{}, nil + } + + // 改号:事务内三步走对调(规避 UNIQUE(event_id, table_id, seat_no)) + now := gtime.Now() + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + briefs, e := loadSeatBriefs(ctx, tx, req.EventId, tableId) + if e != nil { + return e + } + plan, e := planSeatSwap(briefs, req.SeatId, req.SeatNo) + if e != nil { + return e + } + if plan.SeatBId == 0 { + return nil // 幂等 + } + // 1) B 先挪到中转号(确保后续写入不撞唯一键) + if _, e = tx.Model(consts.TableEventSeats).Where("id", plan.SeatBId).Data(g.Map{ + "seat_no": plan.TempNo, + "updated_at": now, + }).Update(); e != nil { + return e + } + // 2) A 设为目标号(含可能的绑定变更);置 is_manual=1 + aData := g.Map{"seat_no": plan.SeatANewNo, "is_manual": 1, "updated_at": now} + if bindChanged { + aData["employee_id"] = bindValue + } + if _, e = tx.Model(consts.TableEventSeats).Where("id", plan.SeatAId).Data(aData).Update(); e != nil { + return e + } + // 3) B 落到 A 的原号;B 亦被人工调整 → is_manual=1 + if _, e = tx.Model(consts.TableEventSeats).Where("id", plan.SeatBId).Data(g.Map{ + "seat_no": plan.SeatBNewNo, + "is_manual": 1, + "updated_at": now, + }).Update(); e != nil { + return e + } + return nil + }) + if err != nil { + return nil, err + } + return &v1.SeatSaveRes{}, nil +} + +// ============================================================================ +// 自动排座 +// ============================================================================ + +// AdminSeatAuto 自动排座:mode=fill 增量补位(保留手工号)/ reorder 全部重排(覆盖手工号)。 +// +// N4 排除语义: +// - 「不参与自动排序」的桌(exclude_auto=1):其座位与人员**完全不被触碰**(不分配、不重编、 +// 不清手工号、不计入 manual_overridden); +// - 「不参与排座」的员工(event_participants.exclude_assign=1):**永不被分配**,一直留在员工池, +// 并计入响应的 excluded; +// - fail-closed(D4):**全部桌被排除** → 返回 4018,message 列出被排除的桌,不静默「排成功 0 人」。 +func AdminSeatAuto(ctx context.Context, req *v1.SeatAutoReq) (*v1.SeatAutoRes, error) { + if _, err := eventRow(ctx, req.EventId); err != nil { + return nil, err + } + + // 1) 桌快照 + fail-closed 前置校验 + tables, err := loadTableBriefs(ctx, req.EventId) + if err != nil { + return nil, err + } + if err = planAutoGuards(tables); err != nil { + return nil, err + } + excludedTables := map[int64]bool{} + excludedTableIds := make([]int64, 0) + for _, t := range tables { + if t.ExcludeAuto == 1 { + excludedTables[t.Id] = true + excludedTableIds = append(excludedTableIds, t.Id) + } + } + + // 2) 座位快照,标记排除桌 + briefs, err := loadSeatBriefs(ctx, g.DB(), req.EventId, 0) + if err != nil { + return nil, err + } + for i := range briefs { + if excludedTables[briefs[i].TableId] { + briefs[i].Excluded = true + } + } + + // 3) 未分配员工,剔除「不参与排座」者 + unassigned, err := unassignedEmployeeIds(ctx, req.EventId) + if err != nil { + return nil, err + } + parts, err := g.Model(consts.TableEventParticipants).Where("event_id", req.EventId).All() + if err != nil { + return nil, err + } + excludedEmp := map[int64]bool{} + excludedCount := 0 + for _, p := range parts { + if p["exclude_assign"].Int() == 1 { + excludedEmp[p["employee_id"].Int64()] = true + excludedCount++ + } + } + if excludedCount > 0 { + kept := make([]int64, 0, len(unassigned)) + for _, id := range unassigned { + if !excludedEmp[id] { + kept = append(kept, id) + } + } + unassigned = kept + } + + assigns, remaining, manualOverridden := planAutoAssign(briefs, unassigned, req.Mode) + now := gtime.Now() + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if req.Mode == consts.SeatModeReorder { + // 覆盖含手工号在内的编号并清空手工标记;但**排除桌的手工号不动** + model := tx.Model(consts.TableEventSeats). + Where("event_id", req.EventId).Where("is_manual", 1) + if len(excludedTableIds) > 0 { + model = model.WhereNotIn("table_id", excludedTableIds) + } + if _, err := model.Data(g.Map{"is_manual": 0, "updated_at": now}).Update(); err != nil { + return err + } + } + for _, a := range assigns { + if _, err := tx.Model(consts.TableEventSeats).Where("id", a.SeatId).Data(g.Map{ + "employee_id": a.EmployeeId, + "updated_at": now, + }).Update(); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &v1.SeatAutoRes{ + Assigned: len(assigns), + Unassigned: len(remaining), + ManualOverridden: manualOverridden, + Excluded: excludedCount, + }, nil +} + +// loadTableBriefs 读取该活动的桌(排除校验用快照)。 +func loadTableBriefs(ctx context.Context, eventId int64) ([]tableBrief, error) { + records, err := g.Model(consts.TableEventTables).Where("event_id", eventId).OrderAsc("id").All() + if err != nil { + return nil, err + } + out := make([]tableBrief, 0, len(records)) + for _, r := range records { + out = append(out, tableBrief{ + Id: r["id"].Int64(), + TableNo: r["table_no"].String(), + Name: r["name"].String(), + ExcludeAuto: r["exclude_auto"].Int(), + }) + } + return out, nil +} + +// ============================================================================ +// 数据读取辅助 +// ============================================================================ + +// querier 抽象 g.DB() 与 gdb.TX 的公共查询能力(Model 方法) +type querier interface { + Model(tableNameOrStruct ...interface{}) *gdb.Model +} + +func loadTables(ctx context.Context, eventId int64) ([]*entity.EventTables, error) { + records, err := g.Model(consts.TableEventTables).Where("event_id", eventId).OrderAsc("id").All() + if err != nil { + return nil, err + } + out := make([]*entity.EventTables, 0, len(records)) + for _, r := range records { + t := &entity.EventTables{} + if err = r.Struct(t); err != nil { + continue + } + out = append(out, t) + } + return out, nil +} + +func loadSeats(ctx context.Context, eventId int64) ([]*entity.EventSeats, error) { + records, err := g.Model(consts.TableEventSeats). + Where("event_id", eventId). + OrderAsc("table_id").OrderAsc("seat_no").All() + if err != nil { + return nil, err + } + out := make([]*entity.EventSeats, 0, len(records)) + for _, r := range records { + s := &entity.EventSeats{} + if err = r.Struct(s); err != nil { + continue + } + out = append(out, s) + } + return out, nil +} + +// loadSeatBriefs 读取座位快照;tableId=0 表示整场活动。 +func loadSeatBriefs(ctx context.Context, q querier, eventId, tableId int64) ([]seatBrief, error) { + model := q.Model(consts.TableEventSeats).Where("event_id", eventId) + if tableId > 0 { + model = model.Where("table_id", tableId) + } + records, err := model.OrderAsc("table_id").OrderAsc("seat_no").All() + if err != nil { + return nil, err + } + out := make([]seatBrief, 0, len(records)) + for _, r := range records { + empId := int64(0) + if v := r["employee_id"]; !v.IsNil() { + empId = v.Int64() + } + out = append(out, seatBrief{ + Id: r["id"].Int64(), + TableId: r["table_id"].Int64(), + SeatNo: r["seat_no"].Int(), + IsManual: r["is_manual"].Int() == 1, + EmployeeId: empId, + }) + } + return out, nil +} + +// unassignedParticipants 候场名单中未绑定任何座位的成员(含姓名/手机/部门)。 +func unassignedParticipants(ctx context.Context, eventId int64) ([]v1.UnassignedItem, error) { + ids, err := unassignedEmployeeIds(ctx, eventId) + if err != nil { + return nil, err + } + out := make([]v1.UnassignedItem, 0, len(ids)) + if len(ids) == 0 { + return out, nil + } + records, err := g.Model(consts.TableEmployees).WhereIn("id", ids).All() + if err != nil { + return nil, err + } + nameMap := map[int64]v1.UnassignedItem{} + for _, r := range records { + nameMap[r["id"].Int64()] = v1.UnassignedItem{ + EmployeeId: r["id"].Int64(), + Name: r["name"].String(), + Phone: r["phone"].String(), + Dept: r["dept"].String(), + } + } + for _, id := range ids { + if item, ok := nameMap[id]; ok { + out = append(out, item) + } else { + out = append(out, v1.UnassignedItem{EmployeeId: id}) + } + } + return out, nil +} + +// unassignedEmployeeIds 候场名单员工 id,剔除已绑定座位的;保持候场顺序(按 participant id)。 +func unassignedEmployeeIds(ctx context.Context, eventId int64) ([]int64, error) { + parts, err := g.Model(consts.TableEventParticipants). + Where("event_id", eventId).OrderAsc("id").All() + if err != nil { + return nil, err + } + boundRecords, err := g.Model(consts.TableEventSeats). + Where("event_id", eventId).WhereNotNull("employee_id").All() + if err != nil { + return nil, err + } + bound := make(map[int64]bool, len(boundRecords)) + for _, r := range boundRecords { + bound[r["employee_id"].Int64()] = true + } + out := make([]int64, 0, len(parts)) + for _, p := range parts { + id := p["employee_id"].Int64() + if !bound[id] { + out = append(out, id) + } + } + return out, nil +} + +func collectEmployeeIds(seats []*entity.EventSeats) []int64 { + seen := map[int64]bool{} + out := make([]int64, 0) + for _, s := range seats { + if s.EmployeeId != nil && !seen[*s.EmployeeId] { + seen[*s.EmployeeId] = true + out = append(out, *s.EmployeeId) + } + } + return out +} + +func loadEmployeeNames(ctx context.Context, ids []int64) (map[int64]string, error) { + out := map[int64]string{} + if len(ids) == 0 { + return out, nil + } + records, err := g.Model(consts.TableEmployees).WhereIn("id", ids).All() + if err != nil { + return nil, err + } + for _, r := range records { + out[r["id"].Int64()] = r["name"].String() + } + return out, nil +} + +// parseEventTime 解析活动时间字符串;空串表示不设。 +func parseEventTime(s string) (*gtime.Time, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + t, err := gtime.StrToTime(s) + if err != nil { + return nil, gerror.NewCode(gcode.CodeValidationFailed, "活动时间格式不正确") + } + return t, nil +} + +// formatGTime 时间格式化为 yyyy-MM-dd HH:mm:ss;空返回空串。 +func formatGTime(t *gtime.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("Y-m-d H:i:s") +} diff --git a/internal/logic/event_public.go b/internal/logic/event_public.go new file mode 100644 index 0000000..0cda0ce --- /dev/null +++ b/internal/logic/event_public.go @@ -0,0 +1,43 @@ +package logic + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + + v1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 年会域:公开「可查询活动列表」(N4-A / E-list,免登录) +// 依据:增量架构 §4.3(E-list)、§2.3;PRD-02 查座入口。 +// +// 契约: +// - 只返回已发布(status=published,即 can_query=true)的活动; +// - 无已发布活动 → {list:[]}(**非报错**,前端据此渲染空态); +// - 草稿(draft)/ 已结束(ended)**不出现**; +// - 只暴露最小字段(event_id / title / venue / event_time),不含任何组织 / 名单信息。 +// ============================================================================ + +// EventPublicList 公开可查询活动列表。 +func EventPublicList(ctx context.Context) (*v1.EventPublicListRes, error) { + records, err := g.Model(consts.TableAnnualEvents). + Where("status", consts.EventStatusPublished). + OrderDesc("id"). + All() + if err != nil { + return nil, err + } + // 用 make(...,0,...) 保证空结果序列化为 [](而非 null)。 + list := make([]v1.EventPublicItem, 0, len(records)) + for _, r := range records { + list = append(list, v1.EventPublicItem{ + EventId: r["id"].Int64(), + Title: r["title"].String(), + Venue: r["venue"].String(), + EventTime: formatGTime(r["event_time"].GTime()), + }) + } + return &v1.EventPublicListRes{List: list}, nil +} diff --git a/internal/logic/event_public_test.go b/internal/logic/event_public_test.go new file mode 100644 index 0000000..9b8a4f0 --- /dev/null +++ b/internal/logic/event_public_test.go @@ -0,0 +1,72 @@ +package logic + +import ( + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" +) + +// ============================================================================ +// 公开活动列表过滤单测(T02.4) +// +// 真实库集成测试(IT_DB 门控):只返已发布(can_query=true);草稿 / 已结束不出现; +// 空结果非报错(list 非 nil)。探测数据用极大假 enterprise_id 隔离,结束即清理。 +// ============================================================================ + +const probeEventEntId = int64(900000000000000002) + +func TestEventPublicListOnlyPublished(t *testing.T) { + ctx := requireOrderIT(t) + cleanup := func() { + _, _ = g.DB().Exec(ctx, "DELETE FROM annual_events WHERE enterprise_id=?", probeEventEntId) + } + cleanup() + defer cleanup() + + // 草稿 + if _, err := g.Model(consts.TableAnnualEvents).Data(g.Map{ + "enterprise_id": probeEventEntId, "title": "__probe_draft__", + "status": consts.EventStatusDraft, "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).Insert(); err != nil { + t.Fatalf("insert draft event: %v", err) + } + // 已发布 + pubId, err := g.Model(consts.TableAnnualEvents).Data(g.Map{ + "enterprise_id": probeEventEntId, "title": "__probe_pub__", "venue": "探测场地", + "event_time": gtime.Now(), "status": consts.EventStatusPublished, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert published event: %v", err) + } + // 已结束 + if _, err = g.Model(consts.TableAnnualEvents).Data(g.Map{ + "enterprise_id": probeEventEntId, "title": "__probe_ended__", + "status": consts.EventStatusEnded, "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).Insert(); err != nil { + t.Fatalf("insert ended event: %v", err) + } + + res, err := EventPublicList(ctx) + if err != nil { + t.Fatalf("EventPublicList: %v", err) + } + if res.List == nil { + t.Fatalf("list 不应为 nil(无已发布时应为 [])") + } + foundPub := false + for _, it := range res.List { + if it.Title == "__probe_draft__" || it.Title == "__probe_ended__" { + t.Fatalf("草稿 / 已结束活动不应出现:%+v", it) + } + if it.EventId == pubId { + foundPub = true + } + } + if !foundPub { + t.Fatalf("已发布活动(id=%d)应出现在列表", pubId) + } +} diff --git a/internal/logic/event_query.go b/internal/logic/event_query.go new file mode 100644 index 0000000..7d41771 --- /dev/null +++ b/internal/logic/event_query.go @@ -0,0 +1,433 @@ +package logic + +import ( + "context" + "encoding/json" + "sort" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/user/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 年会域:公开查座(免登录) +// 依据:PRD-02 §4.2.3(鉴权与隐私边界)、R7-11~R7-24;架构 §3.2.9、§4.4 图④、§8.6.2。 +// +// 三条红线: +// 1) 只查已发布活动(status=published),否则 4005; +// 2) 姓名+手机号双因子精确匹配,失败文案统一模糊(防枚举); +// 3) 结果最小化:只返回本人桌座 + 本人所在桌示意,绝不返回他人信息 / 名单 / 统计。 +// +// 限流/冷却无 Redis,以 event_seat_queries 时间窗 COUNT 为准(表为权威源)。 +// ============================================================================ + +// seatLookup 命中结果(本人所在桌座) +type seatLookup struct { + TableId int64 + TableNo string + TableName string + SeatNo int + Capacity int + // 本人桌圆心(hall_layout 大厅逻辑坐标系;缺省 0) + TableX int + TableY int +} + +// seatGuardInput 限流/冷却判定入参(纯函数入参,便于单测) +type seatGuardInput struct { + CountLastMinute int + CountLastHour int + ConsecutiveFails int + LastFailAt *time.Time + Now time.Time + CooldownMinutes int + Threshold int + PerMinute int + PerHour int +} + +func gcodeEventUnavailable() gcode.Code { + return gcode.New(consts.CodeEventUnavailable, "", nil) +} + +func gcodeSeat(code int) gcode.Code { + return gcode.New(code, "", nil) +} + +// seatBusyMessage 4006/4007 共用文案(§8.1 允许前端合并展示) +func seatBusyMessage() string { + return "操作过于频繁,请稍后再试" +} + +// seatNotFoundMessage 双因子不匹配/未录入/未分配座位共用文案(防枚举) +func seatNotFoundMessage() string { + return "未查询到您的座位信息,请联系活动组织者确认是否已录入" +} + +// ============================================================================ +// 纯函数 +// ============================================================================ + +// judgeSeatGuard 返回限流判定码:0 放行 / 4006 超频 / 4007 冷却中。 +func judgeSeatGuard(in seatGuardInput) int { + if in.PerMinute > 0 && in.CountLastMinute >= in.PerMinute { + return consts.CodeSeatRateLimited + } + if in.PerHour > 0 && in.CountLastHour >= in.PerHour { + return consts.CodeSeatRateLimited + } + if in.LastFailAt != nil && in.Threshold > 0 && in.ConsecutiveFails >= in.Threshold { + cooldown := time.Duration(in.CooldownMinutes) * time.Minute + if cooldown > 0 && in.Now.Sub(*in.LastFailAt) < cooldown { + return consts.CodeSeatCooling + } + } + return 0 +} + +// consecutiveFails 计算最近连续失败次数(results 需按时间倒序,最近在前)。 +func consecutiveFails(results []int) int { + n := 0 + for _, r := range results { + if r == 0 { + n++ + continue + } + break + } + return n +} + +// maskPhone 手机号脱敏:中间 4 位打码(138****0000,PRD-02 Q15)。 +func maskPhone(p string) string { + if p == "" { + return "" + } + if len(p) <= 4 { + return strings.Repeat("*", len(p)) + } + if len(p) >= 7 { + return p[:3] + "****" + p[len(p)-4:] + } + return p[:2] + "***" + p[len(p)-2:] +} + +// parseStage 从 hall_layout JSON 解析舞台信息;无舞台/解析失败返回 nil(不阻断查座)。 +func parseStage(layout string) *v1.StageOut { + if strings.TrimSpace(layout) == "" { + return nil + } + var parsed struct { + Stage struct { + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + Shape string `json:"shape"` + Rotation int `json:"rotation"` + Color string `json:"color"` + } `json:"stage"` + } + if err := json.Unmarshal([]byte(layout), &parsed); err != nil { + return nil + } + s := parsed.Stage + if s.W == 0 && s.H == 0 && s.Shape == "" { + return nil + } + return &v1.StageOut{X: s.X, Y: s.Y, W: s.W, H: s.H, Shape: s.Shape, Rotation: s.Rotation, Color: s.Color} +} + +// 大厅逻辑坐标系缺省尺寸(与设计器 hall_layout.canvas 缺省一致:qitongxue-admin Designer.vue canvas:{w:1600,h:1200})。 +const ( + defaultHallW = 1600 + defaultHallH = 1200 +) + +// parseCanvas 从 hall_layout JSON 解析大厅逻辑宽高(canvas.w / canvas.h)。 +// 空串 / 解析失败 / 值 ≤0 → 回退 1600×1200(不阻断查座,风格与 parseStage 一致)。 +func parseCanvas(layout string) (w, h int) { + w, h = defaultHallW, defaultHallH + if strings.TrimSpace(layout) == "" { + return + } + var parsed struct { + Canvas struct { + W int `json:"w"` + H int `json:"h"` + } `json:"canvas"` + } + if err := json.Unmarshal([]byte(layout), &parsed); err != nil { + return + } + if parsed.Canvas.W > 0 { + w = parsed.Canvas.W + } + if parsed.Canvas.H > 0 { + h = parsed.Canvas.H + } + return +} + +// buildSeatCells 生成「本人所在桌」示意:只含座位号 + 是否本人 + 是否有人(不含身份)。 +func buildSeatCells(seats []seatBrief, selfNo int) []v1.SeatCell { + sorted := append([]seatBrief(nil), seats...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].SeatNo < sorted[j].SeatNo }) + out := make([]v1.SeatCell, 0, len(sorted)) + for _, s := range sorted { + out = append(out, v1.SeatCell{ + SeatNo: s.SeatNo, + IsSelf: s.SeatNo == selfNo, + Filled: s.EmployeeId != 0, + }) + } + return out +} + +// validSeatQueryInput 查座入参校验(姓名非空≤32、手机号为 6~20 位数字)。 +func validSeatQueryInput(name, phone string) bool { + if name == "" || len([]rune(name)) > 32 { + return false + } + return validPhone(phone) +} + +// ============================================================================ +// 公开接口 +// ============================================================================ + +// EventInfo 活动公开信息(查座页顶部)。活动不存在 → 4005;未发布时 CanQuery=false。 +func EventInfo(ctx context.Context, req *v1.EventInfoReq) (*v1.EventInfoRes, error) { + event, err := publicEventRow(ctx, req.EventId) + if err != nil { + return nil, err + } + return &v1.EventInfoRes{ + Title: event.Title, + Venue: event.Venue, + EventTime: formatGTime(event.EventTime), + Status: event.Status, + CanQuery: event.Status == consts.EventStatusPublished, + Stage: parseStage(event.HallLayout), + }, nil +} + +// EventSeatQuery 扫码查座(免登录):限流 → 双因子精确匹配 → 结果最小化 → 审计。 +func EventSeatQuery(ctx context.Context, req *v1.EventSeatQueryReq) (*v1.EventSeatQueryRes, error) { + event, err := publicEventRow(ctx, req.EventId) + if err != nil { + return nil, err + } + if event.Status != consts.EventStatusPublished { + return nil, gerror.NewCode(gcodeEventUnavailable(), "活动未开放查询") + } + + ip := clientIp(ctx) + openid := "" // 查座免登录,无 openid + + // 限流 / 冷却闸门(被拦的请求不写审计,避免自锁) + if code := seatGuardVerdict(ctx, req.EventId, ip); code != 0 { + return nil, gerror.NewCode(gcodeSeat(code), seatBusyMessage()) + } + + name := strings.TrimSpace(req.Name) + phone := normalizePhone(req.Phone) + // 参数异常 → 拒绝,并计入一次失败(防刷) + if !validSeatQueryInput(name, phone) { + writeSeatAudit(ctx, req.EventId, openid, ip, phone, 0) + return nil, gerror.NewCode(gcode.CodeValidationFailed, "请填写正确的姓名与手机号") + } + + lookup, err := lookupSeat(ctx, req.EventId, name, phone) + if err != nil { + return nil, err + } + if lookup == nil { + writeSeatAudit(ctx, req.EventId, openid, ip, phone, 0) + return &v1.EventSeatQueryRes{Found: false, Message: seatNotFoundMessage()}, nil + } + + res, err := buildSeatQueryRes(ctx, event, lookup) + if err != nil { + return nil, err + } + writeSeatAudit(ctx, req.EventId, openid, ip, phone, 1) + return res, nil +} + +// ============================================================================ +// 内部实现 +// ============================================================================ + +// publicEventRow 读取活动;不存在 → 4005。 +func publicEventRow(ctx context.Context, eventId int64) (*entity.AnnualEvents, error) { + record, err := g.Model(consts.TableAnnualEvents).Where("id", eventId).One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, gerror.NewCode(gcodeEventUnavailable(), "活动不存在或未开放查询") + } + event := &entity.AnnualEvents{} + if err = record.Struct(event); err != nil { + return nil, err + } + return event, nil +} + +// lookupSeat 双因子精确匹配:本人须在企业员工库、且在该活动候场名单、且已绑定座位。 +func lookupSeat(ctx context.Context, eventId int64, name, phone string) (*seatLookup, error) { + const sql = "SELECT s.table_id AS table_id, s.seat_no AS seat_no " + + "FROM employees e " + + "JOIN event_participants p ON p.employee_id = e.id AND p.event_id = ? " + + "JOIN event_seats s ON s.employee_id = e.id AND s.event_id = ? " + + "WHERE e.name = ? AND e.phone = ? LIMIT 1" + records, err := g.DB().GetAll(ctx, sql, eventId, eventId, name, phone) + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, nil + } + tableId := records[0]["table_id"].Int64() + seatNo := records[0]["seat_no"].Int() + + table, err := g.Model(consts.TableEventTables).Where("id", tableId).Where("event_id", eventId).One() + if err != nil { + return nil, err + } + if table.IsEmpty() { + return nil, nil + } + return &seatLookup{ + TableId: tableId, + TableNo: table["table_no"].String(), + TableName: table["name"].String(), + SeatNo: seatNo, + Capacity: table["capacity"].Int(), + TableX: table["x"].Int(), + TableY: table["y"].Int(), + }, nil +} + +// buildSeatQueryRes 组装最小化返回体(本人桌座 + 本桌示意 + 舞台)。 +func buildSeatQueryRes(ctx context.Context, event *entity.AnnualEvents, lookup *seatLookup) (*v1.EventSeatQueryRes, error) { + briefs, err := loadSeatBriefs(ctx, g.DB(), event.Id, lookup.TableId) + if err != nil { + return nil, err + } + // hall_w / hall_h 取自 hall_layout.canvas(缺省 1600/1200)——只用于前端缩放, + // 与舞台同源解析,不引入额外查询。 + hallW, hallH := parseCanvas(event.HallLayout) + return &v1.EventSeatQueryRes{ + Found: true, + Message: "ok", + TableNo: lookup.TableNo, + TableName: lookup.TableName, + SeatNo: lookup.SeatNo, + Capacity: lookup.Capacity, + Seats: buildSeatCells(briefs, lookup.SeatNo), + Stage: parseStage(event.HallLayout), + TableX: lookup.TableX, + TableY: lookup.TableY, + HallW: hallW, + HallH: hallH, + }, nil +} + +// seatGuardVerdict 计算限流/冷却判定码(0 放行)。查询异常时放行(宁漏不误伤)。 +func seatGuardVerdict(ctx context.Context, eventId int64, ip string) int { + perMin, perHour, cooldownMin, threshold := seatGuardParams(ctx) + now := gtime.Now() + + cntMin, err := g.Model(consts.TableEventSeatQueries). + Where("event_id", eventId).Where("ip", ip). + WhereGTE("created_at", now.Add(-time.Minute)).Count() + if err != nil { + return 0 + } + cntHour, err := g.Model(consts.TableEventSeatQueries). + Where("event_id", eventId).Where("ip", ip). + WhereGTE("created_at", now.Add(-time.Hour)).Count() + if err != nil { + return 0 + } + rows, err := g.Model(consts.TableEventSeatQueries). + Where("event_id", eventId).Where("ip", ip). + OrderDesc("id").Limit(20).All() + if err != nil { + return 0 + } + results := make([]int, 0, len(rows)) + var lastFailAt *time.Time + for i, r := range rows { + res := r["result"].Int() + results = append(results, res) + if i == 0 && res == 0 { + if t := r["created_at"].GTime(); t != nil { + lastFailAt = &t.Time + } + } + } + return judgeSeatGuard(seatGuardInput{ + CountLastMinute: cntMin, + CountLastHour: cntHour, + ConsecutiveFails: consecutiveFails(results), + LastFailAt: lastFailAt, + Now: now.Time, + CooldownMinutes: cooldownMin, + Threshold: threshold, + PerMinute: perMin, + PerHour: perHour, + }) +} + +// seatGuardParams 读取阈值(settings 覆盖 > 缺省)。 +func seatGuardParams(ctx context.Context) (perMin, perHour, cooldownMin, threshold int) { + perMin = settingInt(ctx, consts.SettingSeatRateLimitPerMin, consts.DefaultSeatRateLimitPerMin) + perHour = settingInt(ctx, consts.SettingSeatRateLimitPerHour, consts.DefaultSeatRateLimitPerHour) + cooldownMin = settingInt(ctx, consts.SettingSeatFailCooldownMinutes, consts.DefaultSeatFailCooldownMinutes) + threshold = settingInt(ctx, consts.SettingSeatFailThreshold, consts.DefaultSeatFailThreshold) + return +} + +// settingInt 读 settings 整数配置;缺失/非法用默认值。 +func settingInt(ctx context.Context, key string, def int) int { + raw := SettingValue(ctx, key) + if v, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && v > 0 { + return v + } + return def +} + +// writeSeatAudit 写审计(成功/失败都写),手机号脱敏。 +func writeSeatAudit(ctx context.Context, eventId int64, openid, ip, phone string, result int) { + if _, err := g.Model(consts.TableEventSeatQueries).Data(g.Map{ + "event_id": eventId, + "openid": openid, + "ip": ip, + "phone_masked": maskPhone(phone), + "result": result, + "created_at": gtime.Now(), + }).Insert(); err != nil { + g.Log().Warningf(ctx, "[event-seat] 写查座审计失败: %v", err) + } +} + +// clientIp 取客户端 IP(无请求上下文时为空串)。 +func clientIp(ctx context.Context) string { + req := g.RequestFromCtx(ctx) + if req == nil { + return "" + } + return req.GetClientIp() +} diff --git a/internal/logic/event_query_log.go b/internal/logic/event_query_log.go new file mode 100644 index 0000000..3b17ed7 --- /dev/null +++ b/internal/logic/event_query_log.go @@ -0,0 +1,111 @@ +package logic + +import ( + "context" + "strings" + + "github.com/gogf/gf/v2/frame/g" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 年会域:查座审计列表(A19,架构 §5.2 遗漏项补录) +// +// 数据源 event_seat_queries(架构 §3.2.9),与公开查座(event_query.go)共用同一张表: +// - phone_masked 落库时已脱敏(writeSeatAudit → maskPhone),此处原样返回,不再二次脱敏; +// - openid 不做落库脱敏(限流需要原始值),输出时二次脱敏(前 6 位 + ***); +// - event_title 通过一次 IN 查询批量补齐,避免逐条查(N+1)。 +// ============================================================================ + +// AdminEventQueryLog 查座审计列表。 +// +// EventId = 0 表示全部活动; +// Result = -1 表示全部结果,0 失败 / 1 成功(其余取值同样视为全部,避免前端漏传时误过滤)。 +func AdminEventQueryLog(ctx context.Context, req *v1.EventQueryLogReq) (*v1.EventQueryLogRes, error) { + model := g.Model(consts.TableEventSeatQueries) + if req.EventId > 0 { + model = model.Where("event_id", req.EventId) + } + if req.Result == consts.SeatResultFail || req.Result == consts.SeatResultSuccess { + model = model.Where("result", req.Result) + } + + total, err := model.Count() + if err != nil { + return nil, err + } + page, size := normalizeAdminPage(req.Page, req.PageSize) + records, err := model.OrderDesc("id").Page(page, size).All() + if err != nil { + return nil, err + } + + // 解析实体 + 收集去重后的活动 id + rows := make([]*entity.EventSeatQueries, 0, len(records)) + eventIds := make([]int64, 0) + seen := make(map[int64]bool) + for _, r := range records { + item := &entity.EventSeatQueries{} + if err = r.Struct(item); err != nil { + continue + } + rows = append(rows, item) + if item.EventId > 0 && !seen[item.EventId] { + seen[item.EventId] = true + eventIds = append(eventIds, item.EventId) + } + } + + // 批量补齐活动标题(一次 IN 查询,避免 N+1) + titleMap, err := loadEventTitles(ctx, eventIds) + if err != nil { + return nil, err + } + + list := make([]v1.QueryLogItem, 0, len(rows)) + for _, item := range rows { + list = append(list, v1.QueryLogItem{ + Id: item.Id, + EventId: item.EventId, + EventTitle: titleMap[item.EventId], + Ip: item.Ip, + PhoneMasked: item.PhoneMasked, + OpenidMasked: maskOpenid(item.Openid), + Result: item.Result, + CreatedAt: formatGTime(item.CreatedAt), + }) + } + return &v1.EventQueryLogRes{List: list, Total: total}, nil +} + +// loadEventTitles 批量读取活动标题:id -> title(一次 IN 查询,避免 N+1)。 +func loadEventTitles(ctx context.Context, eventIds []int64) (map[int64]string, error) { + out := make(map[int64]string, len(eventIds)) + if len(eventIds) == 0 { + return out, nil + } + records, err := g.Model(consts.TableAnnualEvents).Fields("id", "title").WhereIn("id", eventIds).All() + if err != nil { + return nil, err + } + for _, r := range records { + out[r["id"].Int64()] = r["title"].String() + } + return out, nil +} + +// maskOpenid openid 脱敏:保留前 6 位 + "***";空串返回空串。 +// 说明:openid 落库未脱敏(限流需原始值比对),仅在审计列表输出时脱敏。 +func maskOpenid(openid string) string { + openid = strings.TrimSpace(openid) + if openid == "" { + return "" + } + if len(openid) <= 6 { + return openid + "***" + } + return openid[:6] + "***" +} diff --git a/internal/logic/event_query_log_test.go b/internal/logic/event_query_log_test.go new file mode 100644 index 0000000..3f58e1b --- /dev/null +++ b/internal/logic/event_query_log_test.go @@ -0,0 +1,53 @@ +package logic + +import ( + "testing" + + "tool-api/internal/consts" +) + +// TestMaskOpenid 覆盖 A19 的 openid 脱敏:保留前 6 位 + "***",空串原样返回。 +func TestMaskOpenid(t *testing.T) { + cases := map[string]string{ + "": "", + " ": "", + "oABCDEFghijklmnop": "oABCDE***", + "oABCDE": "oABCDE***", // 恰好 6 位 + "abc": "abc***", // 不足 6 位:全保留 + *** + "o6_bmjrPTlm6_2sgVt7hMZOPfL2M": "o6_bmj***", + } + for in, want := range cases { + if got := maskOpenid(in); got != want { + t.Fatalf("maskOpenid(%q)=%q want %q", in, got, want) + } + } +} + +// TestSeatResultEnum 审计结果枚举与契约一致(0 失败 / 1 成功 / -1 全部)。 +func TestSeatResultEnum(t *testing.T) { + if consts.SeatResultFail != 0 { + t.Fatalf("SeatResultFail=%d want 0", consts.SeatResultFail) + } + if consts.SeatResultSuccess != 1 { + t.Fatalf("SeatResultSuccess=%d want 1", consts.SeatResultSuccess) + } + if consts.SeatResultAll != -1 { + t.Fatalf("SeatResultAll=%d want -1", consts.SeatResultAll) + } +} + +// TestQueryLogFilterVerdict 校验结果筛选判据:仅 0/1 触发过滤,其余(含 -1)视为全部。 +func TestQueryLogFilterVerdict(t *testing.T) { + shouldFilter := func(result int) bool { + return result == consts.SeatResultFail || result == consts.SeatResultSuccess + } + if !shouldFilter(0) || !shouldFilter(1) { + t.Fatalf("0/1 应触发结果过滤") + } + if shouldFilter(-1) { + t.Fatalf("-1(全部)不应过滤") + } + if shouldFilter(99) { + t.Fatalf("非法值应视为全部,不应过滤") + } +} diff --git a/internal/logic/event_query_test.go b/internal/logic/event_query_test.go new file mode 100644 index 0000000..a164d6c --- /dev/null +++ b/internal/logic/event_query_test.go @@ -0,0 +1,218 @@ +package logic + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + v1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// TestJudgeSeatGuardRate 覆盖判据④:≥5 次/分钟 或 ≥20 次/小时 → 4006。 +func TestJudgeSeatGuardRate(t *testing.T) { + now := time.Now() + if code := judgeSeatGuard(seatGuardInput{CountLastMinute: 6, PerMinute: 5, PerHour: 20, Now: now}); code != consts.CodeSeatRateLimited { + t.Fatalf("1 分钟第 6 次应 4006,got %d", code) + } + if code := judgeSeatGuard(seatGuardInput{CountLastMinute: 5, PerMinute: 5, PerHour: 20, Now: now}); code != consts.CodeSeatRateLimited { + t.Fatalf("1 分钟第 5 次(≥阈值)应 4006,got %d", code) + } + if code := judgeSeatGuard(seatGuardInput{CountLastHour: 20, PerMinute: 5, PerHour: 20, Now: now}); code != consts.CodeSeatRateLimited { + t.Fatalf("1 小时第 20 次应 4006,got %d", code) + } + if code := judgeSeatGuard(seatGuardInput{CountLastMinute: 4, CountLastHour: 19, PerMinute: 5, PerHour: 20, Now: now}); code != 0 { + t.Fatalf("未达阈值应放行,got %d", code) + } +} + +// TestJudgeSeatGuardCooling 覆盖判据⑤:连续失败 5 次 → 4007 冷却 10 分钟;冷却结束恢复。 +func TestJudgeSeatGuardCooling(t *testing.T) { + now := time.Now() + in := seatGuardInput{ + CountLastMinute: 1, CountLastHour: 3, + ConsecutiveFails: 5, Threshold: 5, CooldownMinutes: 10, + PerMinute: 5, PerHour: 20, Now: now, + } + // 最近一次失败在冷却窗口内 → 4007(即使输入正确也应冷却) + recentFail := now.Add(-1 * time.Minute) + in.LastFailAt = &recentFail + if code := judgeSeatGuard(in); code != consts.CodeSeatCooling { + t.Fatalf("冷却期内应 4007,got %d", code) + } + // 冷却已过 → 放行 + oldFail := now.Add(-11 * time.Minute) + in.LastFailAt = &oldFail + if code := judgeSeatGuard(in); code != 0 { + t.Fatalf("冷却结束应放行,got %d", code) + } + // 连续失败不足阈值 → 放行 + in.ConsecutiveFails = 4 + in.LastFailAt = &recentFail + if code := judgeSeatGuard(in); code != 0 { + t.Fatalf("失败次数不足阈值应放行,got %d", code) + } +} + +// TestConsecutiveFails 最近连续失败计数(倒序)。 +func TestConsecutiveFails(t *testing.T) { + if n := consecutiveFails([]int{0, 0, 0, 1, 0}); n != 3 { + t.Fatalf("got %d want 3", n) + } + if n := consecutiveFails([]int{1, 0}); n != 0 { + t.Fatalf("got %d want 0", n) + } + if n := consecutiveFails(nil); n != 0 { + t.Fatalf("got %d want 0", n) + } +} + +// TestMaskPhone 覆盖判据⑦的脱敏:中间 4 位打码。 +func TestMaskPhone(t *testing.T) { + cases := map[string]string{ + "13800000000": "138****0000", + "13912345678": "139****5678", + "": "", + "1234": "****", + } + for in, want := range cases { + if got := maskPhone(in); got != want { + t.Fatalf("maskPhone(%q)=%q want %q", in, got, want) + } + } +} + +// TestParseStage 舞台解析:无舞台/非法 JSON 返回 nil,不阻断查座。 +func TestParseStage(t *testing.T) { + layout := `{"canvas":{"w":1600,"h":1200},"stage":{"x":10,"y":20,"w":300,"h":80,"shape":"rect","rotation":0,"color":"#fff"}}` + s := parseStage(layout) + if s == nil || s.X != 10 || s.W != 300 || s.Shape != "rect" { + t.Fatalf("解析舞台失败: %+v", s) + } + if parseStage("") != nil { + t.Fatalf("空布局应返回 nil") + } + if parseStage("{not json") != nil { + t.Fatalf("非法 JSON 应返回 nil") + } + if parseStage(`{"canvas":{"w":1600,"h":1200}}`) != nil { + t.Fatalf("无 stage 应返回 nil") + } +} + +// TestEventSeatQueryParseCanvas 大厅尺寸解析:取 canvas.w/h;空串/非法/≤0 一律回退 1600/1200。 +func TestEventSeatQueryParseCanvas(t *testing.T) { + // 设计器/测试样本:canvas:{w:1600,h:1200} + if w, h := parseCanvas(`{"canvas":{"w":1600,"h":1200},"stage":{"x":10,"y":20,"w":300,"h":80,"shape":"rect"}}`); w != 1600 || h != 1200 { + t.Fatalf("标准样本应解析为 1600x1200,got %dx%d", w, h) + } + // 空串 → 回退缺省 + if w, h := parseCanvas(""); w != 1600 || h != 1200 { + t.Fatalf("空布局应回退 1600x1200,got %dx%d", w, h) + } + // 非法 JSON → 回退缺省(不阻断) + if w, h := parseCanvas("{not json"); w != 1600 || h != 1200 { + t.Fatalf("非法 JSON 应回退 1600x1200,got %dx%d", w, h) + } + // 值为 0/负数 → 回退缺省 + if w, h := parseCanvas(`{"canvas":{"w":0,"h":0}}`); w != 1600 || h != 1200 { + t.Fatalf("非正值应回退缺省,got %dx%d", w, h) + } + // 自定义尺寸 → 原样返回 + if w, h := parseCanvas(`{"canvas":{"w":800,"h":600}}`); w != 800 || h != 600 { + t.Fatalf("自定义尺寸应原样返回,got %dx%d", w, h) + } +} + +// TestEventSeatQueryResTableCoordTags 固化与前端约定一致的下划线字段名与取值 +// (json tag 写错会静默变 undefined → 前端永远走降级;此测试防回归)。 +func TestEventSeatQueryResTableCoordTags(t *testing.T) { + b, err := json.Marshal(v1.EventSeatQueryRes{Found: true, TableX: 1100, TableY: 850, HallW: 1600, HallH: 1200}) + if err != nil { + t.Fatalf("marshal 失败: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal 失败: %v", err) + } + for _, k := range []string{"table_x", "table_y", "hall_w", "hall_h"} { + if _, ok := m[k]; !ok { + t.Fatalf("缺字段 %q(json tag 可能写错): %s", k, string(b)) + } + } + if v, _ := m["table_x"].(float64); v != 1100 { + t.Fatalf("table_x=%v want 1100", m["table_x"]) + } + if v, _ := m["hall_h"].(float64); v != 1200 { + t.Fatalf("hall_h=%v want 1200", m["hall_h"]) + } + // 未命中(零值)时 4 字段应为 0(前端据此降级) + b2, _ := json.Marshal(v1.EventSeatQueryRes{Found: false, Message: seatNotFoundMessage()}) + var m2 map[string]any + if err := json.Unmarshal(b2, &m2); err != nil { + t.Fatalf("unmarshal 失败: %v", err) + } + for _, k := range []string{"table_x", "table_y", "hall_w", "hall_h"} { + if v, _ := m2[k].(float64); v != 0 { + t.Fatalf("未命中 %s 应为 0,got %v", k, m2[k]) + } + } +} + +// TestBuildSeatCells 桌面示意图:只含座位号/是否本人/是否有人。 +func TestBuildSeatCells(t *testing.T) { + seats := []seatBrief{ + {Id: 1, TableId: 1, SeatNo: 1}, + {Id: 2, TableId: 1, SeatNo: 2, EmployeeId: 99}, + {Id: 3, TableId: 1, SeatNo: 3}, + } + cells := buildSeatCells(seats, 2) + if len(cells) != 3 { + t.Fatalf("len=%d want 3", len(cells)) + } + if cells[0].SeatNo != 1 || cells[0].IsSelf || cells[0].Filled { + t.Fatalf("1 号座应有/无人标记正确: %+v", cells[0]) + } + if cells[1].SeatNo != 2 || !cells[1].IsSelf || !cells[1].Filled { + t.Fatalf("2 号座应为本人且有人: %+v", cells[1]) + } +} + +// TestSeatQueryResultMinimized 覆盖判据⑥:返回结构不含任何他人/隐私字段。 +func TestSeatQueryResultMinimized(t *testing.T) { + forbidden := map[string]bool{ + "name": true, "phone": true, "mobile": true, "nickname": true, + "realname": true, "username": true, "employee_name": true, "employeename": true, + } + seen := map[reflect.Type]bool{} + var walk func(rt reflect.Type, path string) + walk = func(rt reflect.Type, path string) { + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct || seen[rt] { + return + } + seen[rt] = true + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + tag := strings.Split(f.Tag.Get("json"), ",")[0] + if tag == "" || tag == "-" { + tag = f.Name + } + low := strings.ToLower(tag) + if forbidden[low] { + t.Fatalf("EventSeatQueryRes 含潜在他人/隐私字段: %s.%s", path, tag) + } + walk(f.Type, path+"."+tag) + } + } + walk(reflect.TypeOf(v1.EventSeatQueryRes{}), "res") + + // SeatCell 必须恰好 3 个字段(seat_no / is_self / filled) + if n := reflect.TypeOf(v1.SeatCell{}).NumField(); n != 3 { + t.Fatalf("SeatCell 字段数=%d want 3", n) + } +} diff --git a/internal/logic/event_seat_test.go b/internal/logic/event_seat_test.go new file mode 100644 index 0000000..b8af6d4 --- /dev/null +++ b/internal/logic/event_seat_test.go @@ -0,0 +1,229 @@ +package logic + +import ( + "testing" + + "tool-api/internal/consts" +) + +// TestPlanSeatNumbers 覆盖判据②:新增桌自动生成 1..N 座。 +func TestPlanSeatNumbers(t *testing.T) { + got := planSeatNumbers(5) + want := []int{1, 2, 3, 4, 5} + if len(got) != len(want) { + t.Fatalf("planSeatNumbers(5) len=%d want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("planSeatNumbers(5)=%v want %v", got, want) + } + } + if n := len(planSeatNumbers(0)); n != 0 { + t.Fatalf("planSeatNumbers(0) len=%d want 0", n) + } + if n := len(planSeatNumbers(-3)); n != 0 { + t.Fatalf("planSeatNumbers(-3) len=%d want 0", n) + } +} + +// fullSeats 构造 1..n 的座位快照,空位。 +func fullSeats(n int) []seatBrief { + out := make([]seatBrief, 0, n) + for i := 1; i <= n; i++ { + out = append(out, seatBrief{Id: int64(i), TableId: 1, SeatNo: i}) + } + return out +} + +// TestPlanCapacityChangeIncrease 覆盖判据③(增大):10→12 补出 11、12 空位。 +func TestPlanCapacityChangeIncrease(t *testing.T) { + toAdd, toRemove := planCapacityChange(fullSeats(10), 12) + if len(toRemove) != 0 { + t.Fatalf("增大容量不应移除座位,got %v", toRemove) + } + if len(toAdd) != 2 || toAdd[0] != 11 || toAdd[1] != 12 { + t.Fatalf("toAdd=%v want [11 12]", toAdd) + } +} + +// TestPlanCapacityChangeDecrease 覆盖判据③(减小):10→8 移除 9、10 号,且能识别其上员工(移入未分配)。 +func TestPlanCapacityChangeDecrease(t *testing.T) { + seats := fullSeats(10) + // 9、10 号位坐着员工 + seats[8].EmployeeId = 201 + seats[9].EmployeeId = 202 + toAdd, toRemove := planCapacityChange(seats, 8) + if len(toAdd) != 0 { + t.Fatalf("减小容量不应补座,got %v", toAdd) + } + if len(toRemove) != 2 { + t.Fatalf("toRemove len=%d want 2(9、10 号)", len(toRemove)) + } + moved := 0 + removedNos := map[int]bool{} + for _, s := range toRemove { + removedNos[s.SeatNo] = true + if s.EmployeeId != 0 { + moved++ + } + } + if !removedNos[9] || !removedNos[10] { + t.Fatalf("应移除 9、10 号,got %v", removedNos) + } + if moved != 2 { + t.Fatalf("应识别 2 名员工将移入未分配,got %d", moved) + } +} + +// TestPlanAutoAssignFillPreservesManual 覆盖判据⑤:fill 跳过并保留手工座位,不动其编号。 +func TestPlanAutoAssignFillPreservesManual(t *testing.T) { + seats := []seatBrief{ + {Id: 1, TableId: 1, SeatNo: 1, IsManual: false, EmployeeId: 0}, + {Id: 2, TableId: 1, SeatNo: 2, IsManual: true, EmployeeId: 0}, // 手工空位:fill 不应占用 + {Id: 3, TableId: 1, SeatNo: 3, IsManual: false, EmployeeId: 0}, + } + assigns, remaining, overridden := planAutoAssign(seats, []int64{101, 102, 103}, consts.SeatModeFill) + + if overridden != 0 { + t.Fatalf("fill 不应覆盖手工标记,got %d", overridden) + } + // 只有 2 个非手工空位,故只分配 2 人,剩 1 人 + if len(assigns) != 2 { + t.Fatalf("fill 应分配 2 人,got %d (%v)", len(assigns), assigns) + } + for _, a := range assigns { + if a.SeatId == 2 { + t.Fatalf("fill 不应占用手工座位 id=2,got %v", assigns) + } + } + if len(remaining) != 1 || remaining[0] != 103 { + t.Fatalf("remaining=%v want [103]", remaining) + } +} + +// TestPlanAutoAssignReorderOverridesManual 覆盖判据⑤:reorder 覆盖手工号并清空手工标记。 +func TestPlanAutoAssignReorderOverridesManual(t *testing.T) { + seats := []seatBrief{ + {Id: 1, TableId: 1, SeatNo: 1, IsManual: false, EmployeeId: 0}, + {Id: 2, TableId: 1, SeatNo: 2, IsManual: true, EmployeeId: 0}, // 手工位,reorder 可用 + {Id: 3, TableId: 1, SeatNo: 3, IsManual: true, EmployeeId: 9}, // 手工位且已坐人 + } + assigns, remaining, overridden := planAutoAssign(seats, []int64{101, 102}, consts.SeatModeReorder) + + if overridden != 2 { + t.Fatalf("reorder 应清空 2 个手工标记,got %d", overridden) + } + if len(assigns) != 2 { + t.Fatalf("reorder 应分配 2 人(含手工空位),got %d (%v)", len(assigns), assigns) + } + // 手工空位 id=2 现在可被占用 + used := map[int64]bool{} + for _, a := range assigns { + used[a.SeatId] = true + } + if !used[2] { + t.Fatalf("reorder 应可使用手工空位 id=2,got %v", assigns) + } + if used[3] { + t.Fatalf("已坐人的座位不应被重分配,got %v", assigns) + } + if len(remaining) != 0 { + t.Fatalf("remaining=%v want []", remaining) + } +} + +// TestPlanAutoAssignKeepsOccupied 已坐人的座位一律保持不动(fill 与 reorder 皆然)。 +func TestPlanAutoAssignKeepsOccupied(t *testing.T) { + seats := []seatBrief{ + {Id: 1, TableId: 1, SeatNo: 1, EmployeeId: 77}, + {Id: 2, TableId: 1, SeatNo: 2, EmployeeId: 0}, + } + for _, mode := range []string{consts.SeatModeFill, consts.SeatModeReorder} { + assigns, _, _ := planAutoAssign(seats, []int64{101}, mode) + if len(assigns) != 1 || assigns[0].SeatId != 2 { + t.Fatalf("mode=%s 应只填 id=2,got %v", mode, assigns) + } + } +} + +// TestPlanSeatSwapInvariant 覆盖 Part 1(对调):对调后两座编号互换,且全桌仍为无重复 1..capacity。 +func TestPlanSeatSwapInvariant(t *testing.T) { + seats := fullSeats(10) // 1..10,Id == SeatNo + before := append([]seatBrief(nil), seats...) + + // 把 3 号座(id=3)改成 8 号 + plan, err := planSeatSwap(seats, 3, 8) + if err != nil { + t.Fatalf("对调不应报错: %v", err) + } + if plan.SeatBId != 8 || plan.SeatANewNo != 8 || plan.SeatBNewNo != 3 { + t.Fatalf("对调计划错误: %+v", plan) + } + if plan.TempNo <= 10 { + t.Fatalf("中转号必须避开现有编号,got %d", plan.TempNo) + } + // 入参快照不应被修改(保证「目标不存在时数据不变」这一性质的基础) + for i := range seats { + if seats[i] != before[i] { + t.Fatalf("planSeatSwap 不应修改入参快照") + } + } + + // 模拟事务三步(B→temp,A→target,B→cur)后的编号集合,断言不变式:无重复 1..10 + nos := make([]int, len(seats)) + for i, s := range seats { + nos[i] = s.SeatNo + } + apply := func(id int64, no int) { + for i := range seats { + if seats[i].Id == id { + nos[i] = no + } + } + } + apply(plan.SeatBId, plan.TempNo) + apply(plan.SeatAId, plan.SeatANewNo) + apply(plan.SeatBId, plan.SeatBNewNo) + + seen := map[int]bool{} + for i, n := range nos { + if n < 1 || n > 10 { + t.Fatalf("编号越界: %d(全桌=%v)", n, nos) + } + if seen[n] { + t.Fatalf("编号重复: %d(全桌=%v)", n, nos) + } + seen[n] = true + // A→8、B(原 8 号)→3 + if seats[i].Id == plan.SeatAId && n != 8 { + t.Fatalf("A 应对调到 8 号,got %d", n) + } + if seats[i].Id == plan.SeatBId && n != 3 { + t.Fatalf("B 应对调到 3 号,got %d", n) + } + } + if len(seen) != 10 { + t.Fatalf("全桌应仍为 10 个不同编号,got %v", nos) + } + // 员工绑定未被搬运:planSeatSwap 不携带 employee 字段,AdminSeatSave 仅在请求改绑定时才写 + // employee_id —— 故对调路径下 A 行的 employee_id 保持不变(保证查座显示「8 号·张三」)。 +} + +// TestPlanSeatSwapTargetMissing 目标号不存在/超范围 → 报错;快照不变;同号幂等。 +func TestPlanSeatSwapTargetMissing(t *testing.T) { + seats := fullSeats(10) + if _, err := planSeatSwap(seats, 3, 11); err == nil { + t.Fatalf("目标号 11 超出 1..capacity 应报错") + } + if _, err := planSeatSwap(seats, 3, 0); err == nil { + t.Fatalf("目标号 0 非法应报错") + } + if p, err := planSeatSwap(seats, 3, 3); err != nil || p.SeatBId != 0 { + t.Fatalf("目标号等于当前号应幂等(SeatBId=0),got %+v err=%v", p, err) + } + // 目标号不存在于该桌(换到别的 table 的号)—— 用只含 1..5 的桌模拟 + small := fullSeats(5) + if _, err := planSeatSwap(small, 3, 8); err == nil { + t.Fatalf("该桌没有 8 号座位,应报错") + } +} diff --git a/internal/logic/jwt.go b/internal/logic/jwt.go index 625dbb7..e8576b8 100644 --- a/internal/logic/jwt.go +++ b/internal/logic/jwt.go @@ -113,7 +113,13 @@ func CtxLevelKey(ctx context.Context) string { } func CtxAdminId(ctx context.Context) int64 { - return g.RequestFromCtx(ctx).GetCtxVar(consts.CtxAdminId).Int64() + // 非 HTTP 上下文(如单测 / 后台任务)无 *ghttp.Request:返回 0,避免 None 解引用 panic。 + // 与 CtxAdminAccount / CtxAdminRole / CtxAdminName(admin_perm.go)的 nil 处理保持一致。 + req := g.RequestFromCtx(ctx) + if req == nil { + return 0 + } + return req.GetCtxVar(consts.CtxAdminId).Int64() } func IsDebug(ctx context.Context) bool { diff --git a/internal/logic/member.go b/internal/logic/member.go index 80e7990..751eba0 100644 --- a/internal/logic/member.go +++ b/internal/logic/member.go @@ -67,14 +67,42 @@ func MemberCenter(ctx context.Context) (*v1.MemberCenterRes, error) { return res, nil } -// MemberOrderCreate 创建会员订单,返回前端拉支付所需的 payData -func MemberOrderCreate(ctx context.Context, planKey, code string) (*v1.MemberOrderCreateRes, error) { +// MemberOrderCreate 创建会员订单(**只建单**,不返回支付参数;N1-1/2)。 +// +// 同一用户 + 同一套餐 + 待支付 的订单存在则复用(reused=true),保证待支付单恒为 1 条。 +// 支付参数由 POST /member/order/pay 单独构建(见 order_pay.go 的 buildPayParams)。 +func MemberOrderCreate(ctx context.Context, planKey string) (*v1.MemberOrderCreateRes, error) { userId := CtxUserId(ctx) if !xpayConfig(ctx).Configured() { return nil, gerror.NewCode(gcodePayFail(), "虚拟支付尚未配置,请在 MP 后台【虚拟支付 → 基本配置】获取 OfferID 与现网 AppKey 后填入 config.yaml") } + plan, err := enabledPlanByKey(ctx, planKey) + if err != nil { + return nil, err + } + res, err := createOrder(ctx, userId, consts.OrderTypeMember, plan.ProductId, plan.PriceCents, + g.Map{"plan_key": plan.PlanKey}, + g.Map{ + "order_type": consts.OrderTypeMember, + "plan_key": plan.PlanKey, + "level_key": plan.LevelKey, + "duration_days": plan.DurationDays, + }) + if err != nil { + return nil, err + } + return &v1.MemberOrderCreateRes{ + OutTradeNo: res.OutTradeNo, + Reused: res.Reused, + OriginPriceCents: res.OriginPriceCents, + PaidPriceCents: res.PaidPriceCents, + Status: res.Status, + }, nil +} +// enabledPlanByKey 取启用的会员套餐;不存在 / 已下架 / 未配置道具 ID 或价格 → 支付失败错误。 +func enabledPlanByKey(ctx context.Context, planKey string) (*entity.MemberPlans, error) { record, err := g.Model(consts.TableMemberPlans). Where("plan_key", planKey).Where("is_enabled", 1).One() if err != nil { @@ -90,131 +118,64 @@ func MemberOrderCreate(ctx context.Context, planKey, code string) (*v1.MemberOrd if plan.ProductId == "" || plan.PriceCents <= 0 { return nil, gerror.NewCode(gcodePayFail(), "套餐未配置微信道具 ID 或价格,请先在后台道具管理中核对") } + return plan, nil +} - pay, err := prepareXpayOrder(ctx, userId, code, plan.ProductId, plan.PriceCents, g.Map{ - "order_type": consts.OrderTypeMember, - "plan_key": plan.PlanKey, - "level_key": plan.LevelKey, - "duration_days": plan.DurationDays, - }) +// MemberOrderDetail 订单详情(O3 / N1-3):非本人单 → 4015(与「不存在」统一文案,防枚举)。 +func MemberOrderDetail(ctx context.Context, outTradeNo string) (*v1.MemberOrderDetailRes, error) { + o, err := mustOwnOrder(ctx, CtxUserId(ctx), outTradeNo) if err != nil { return nil, err } - return &v1.MemberOrderCreateRes{ - OutTradeNo: pay.OutTradeNo, - Mode: consts.XPayMode, - SignData: pay.SignData, - PaySig: pay.PaySig, - Signature: pay.Signature, - Env: pay.Env, - }, nil + // 老订单(加列前)origin/paid 为 0 → 回退「原价 = 实付 = price_cents」(S14/R4)。 + origin, paid := o.OriginPriceCents, o.PaidPriceCents + if origin <= 0 { + origin = o.PriceCents + } + if paid <= 0 { + paid = o.PriceCents + } + res := &v1.MemberOrderDetailRes{ + OutTradeNo: o.OutTradeNo, + OrderType: o.OrderType, + PlanKey: o.PlanKey, + PackKey: o.PackKey, + ToolKey: o.ToolKey, + Times: o.Times, + OriginPriceCents: origin, + PaidPriceCents: paid, + DiscountCents: o.DiscountCents, + PromoKind: o.PromoKind, + PromoCode: o.PromoCode, + UserCouponId: o.UserCouponId, + Status: o.Status, + StatusText: orderStatusText(o.Status), + CreatedAt: timeStr(o.CreatedAt), + DeliveredAt: timeStr(o.DeliveredAt), + } + planNames, _ := planNameMap(ctx) + packNames, _ := quotaPackNameMap(ctx) + toolNames, _ := toolNameMap(ctx) + if o.OrderType == consts.OrderTypeQuota { + res.ToolName = toolNames[o.ToolKey] + res.Title = packNames[o.PackKey] + if res.ToolName != "" && res.Title != "" { + res.Title = res.ToolName + " · " + res.Title + } + } else { + res.PlanName = planNames[o.PlanKey] + res.Title = res.PlanName + } + return res, nil } // ===== 支付链路的公共部分 ===== - -// xpayPayData 前端拉起虚拟支付所需的最小参数集 -type xpayPayData struct { - OutTradeNo string - SignData string - PaySig string - Signature string - Env int -} - -// prepareXpayOrder 会员套餐与次数包共用的下单流程: -// 换 session_key → 校验支付人与登录人一致 → 落订单 → 拼双签名。 // -// orderFields 由调用方提供商品相关列(order_type / plan_key / level_key / duration_days 或 -// pack_key / tool_key / times),公共列(单号、用户、状态、价格)在这里统一写入。 -func prepareXpayOrder( - ctx context.Context, userId int64, code, productId string, priceCents int64, orderFields g.Map, -) (*xpayPayData, error) { - cfg := xpayConfig(ctx) - user, err := getUserById(ctx, userId) - if err != nil { - return nil, err - } - - // 拿 session_key 并校验「支付人 = 登录人」:signature 用 session_key 签名, - // 若 code 属于另一个 openid,签出来的 signature 会被平台拒绝,这里提前拦截给出清晰报错。 - openid, err := RefreshSessionKey(ctx, userId, code) - if err != nil { - return nil, err - } - if user.Openid != "" && openid != "" && openid != user.Openid { - return nil, gerror.NewCode(gcodePayFail(), "登录态与支付账号不一致,请重新进入小程序后再试") - } - if openid == "" { - return nil, gerror.NewCode(gcodePayFail(), "无法确定支付账号,请重新进入小程序后再试") - } - - // 取回最新 session_key(RefreshSessionKey 可能只更新了库,这里从库里读回) - latest, err := getUserById(ctx, userId) - if err != nil { - return nil, err - } - if latest.SessionKey == "" { - return nil, gerror.NewCode(gcodePayFail(), "登录态已失效,请重新进入小程序后再试") - } - - outTradeNo, err := newOutTradeNo() - if err != nil { - return nil, err - } - attach := g.Map{"userId": userId} - for k, v := range orderFields { - attach[k] = v - } - attachBytes, _ := json.Marshal(attach) - - order := g.Map{ - "out_trade_no": outTradeNo, - "wx_order_id": nil, - "user_id": userId, - "openid": openid, - "product_id": productId, - "price_cents": priceCents, - "status": consts.OrderStatusPending, - "pay_channel": consts.PayChannelWx, - "attach": string(attachBytes), - "created_at": gtime.Now(), - "updated_at": gtime.Now(), - } - for k, v := range orderFields { - order[k] = v - } - // plan_key / level_key 是会员专有列,但老表把它们建成了 NOT NULL 且无默认值 - // (次数包订单没有这两个语义)→ 不补值会报 - // `Error 1364: Field 'plan_key' doesn't have a default value`。 - // - // 这里统一兜底成空串,而不是去把库列改成 DEFAULT '': - // level_key 一旦被静默填成空串,发货时 extendMembership 会把用户等级写成空, - // 属于「无声降级」。保持列严格、由唯一的下单入口显式补齐,才是安全的做法。 - for _, k := range []string{"plan_key", "level_key"} { - if _, ok := order[k]; !ok { - order[k] = "" - } - } - if _, err = g.Model(consts.TableMemberOrders).Data(order).Insert(); err != nil { - return nil, err - } - - signData, err := BuildSignData(cfg.OfferId, productId, priceCents, outTradeNo, string(attachBytes), cfg.Env) - if err != nil { - return nil, err - } - // 这三个值必须与 MP 后台【虚拟支付 → 道具管理】里已发布的道具完全一致(区分大小写), - // 否则客户端会报 PRODUCT_ID_NOT_PUBLISH。这里留痕,便于对着后台核对。 - g.Log().Infof(ctx, "[xpay] 下单 outTradeNo=%s type=%v productId=%s priceCents=%d offerId=%s env=%d", - outTradeNo, orderFields["order_type"], productId, priceCents, cfg.OfferId, cfg.Env) - return &xpayPayData{ - OutTradeNo: outTradeNo, - SignData: signData, - PaySig: CalcPaySig(xpayURIPayRequest, signData, cfg.AppKey), - Signature: CalcSignature(signData, latest.SessionKey), - Env: cfg.Env, - }, nil -} +// 两步式改造(N1)后,原「一步式」的 xpayPayData / prepareXpayOrder(建单 + 拼签名耦合、必传 code) +// 已拆分并迁移到 order_pay.go: +// createOrder —— 只建单 / 复用(不校验支付人) +// buildPayParams —— 只建签名(此处才校验「支付人 = 登录人」) +// 会员套餐与次数包两条链路共用这两个函数,保证行为一致(C-6)。 // MemberOrderCheck 查询订单状态;未发货时主动查单兜底补发(前端支付成功回调后轮询此接口) func MemberOrderCheck(ctx context.Context, outTradeNo string) (*v1.MemberOrderCheckRes, error) { @@ -278,6 +239,7 @@ func MemberOrders(ctx context.Context, limit int) (*v1.MemberOrdersRes, error) { OrderType: orderType, PlanKey: r["plan_key"].String(), PlanName: planNames[r["plan_key"].String()], + PackKey: r["pack_key"].String(), ToolKey: r["tool_key"].String(), ToolName: toolNames[r["tool_key"].String()], Times: r["times"].Int(), @@ -287,6 +249,12 @@ func MemberOrders(ctx context.Context, limit int) (*v1.MemberOrdersRes, error) { PayChannel: r["pay_channel"].String(), CreatedAt: r["created_at"].String(), DeliveredAt: r["delivered_at"].String(), + // T04.6:优惠凭证快照(老订单无此列时读回 0/空串,展示层按原价=实付处理) + OriginPriceCents: r["origin_price_cents"].Int64(), + PaidPriceCents: r["paid_price_cents"].Int64(), + PromoCode: r["promo_code"].String(), + DiscountCents: r["discount_cents"].Int64(), + PromoKind: r["promo_kind"].Int(), } if orderType == consts.OrderTypeQuota { out.Title = packNames[r["pack_key"].String()] diff --git a/internal/logic/migrate.go b/internal/logic/migrate.go index bd61610..359f731 100644 --- a/internal/logic/migrate.go +++ b/internal/logic/migrate.go @@ -116,6 +116,197 @@ func migrateTables() []migrateTable { "`updated_at` datetime DEFAULT NULL," + "PRIMARY KEY (`k`)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台可改的全局配置'"}, + // ===== 笔记域 ===== + {"notes", "CREATE TABLE IF NOT EXISTS `notes` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`user_id` bigint NOT NULL," + + "`title` varchar(128) NOT NULL DEFAULT '' COMMENT '标题'," + + "`content` text COMMENT '正文'," + + "`tags` varchar(255) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔'," + + "`is_pinned` tinyint NOT NULL DEFAULT 0 COMMENT '置顶'," + + "`remind_at` datetime DEFAULT NULL COMMENT '提醒时间,NULL=未设置'," + + "`remind_status` tinyint NOT NULL DEFAULT 0 COMMENT '0未设置 1待提醒 2已提醒 3发送失败 4已过期'," + + "`remind_sent_at` datetime DEFAULT NULL," + + "`remind_attempts` int NOT NULL DEFAULT 0," + + "`subscribe_count` int NOT NULL DEFAULT 0 COMMENT '已授权可发送次数'," + + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除(不占额度)'," + + "`created_at` datetime DEFAULT NULL," + + "`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_user_active` (`user_id`,`deleted_at`)," + + "KEY `idx_remind` (`remind_status`,`remind_at`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='笔记'"}, + // ===== 年会域:企业 / 员工 / 活动 ===== + {"enterprises", "CREATE TABLE IF NOT EXISTS `enterprises` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`name` varchar(128) NOT NULL," + + "`contact` varchar(64) NOT NULL DEFAULT ''," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`status` tinyint NOT NULL DEFAULT 1," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`),KEY `idx_name` (`name`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业'"}, + {"employees", "CREATE TABLE IF NOT EXISTS `employees` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`enterprise_id` bigint NOT NULL," + + "`name` varchar(64) NOT NULL," + + "`phone` varchar(20) NOT NULL," + + "`dept` varchar(64) NOT NULL DEFAULT ''," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`status` tinyint NOT NULL DEFAULT 1," + + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," + + "`deleted_from` text COMMENT '软删前 phone 原值(JSON),便于恢复'," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "UNIQUE KEY `uk_ent_phone` (`enterprise_id`,`phone`)," + + "KEY `idx_ent_name` (`enterprise_id`,`name`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业员工库(跨活动复用)'"}, + {"annual_events", "CREATE TABLE IF NOT EXISTS `annual_events` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`enterprise_id` bigint NOT NULL," + + "`title` varchar(128) NOT NULL," + + "`venue` varchar(128) NOT NULL DEFAULT ''," + + "`event_time` datetime DEFAULT NULL," + + "`status` varchar(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/published/ended'," + + "`hall_layout` text COMMENT '画布/舞台 JSON'," + + "`code_url` varchar(512) NOT NULL DEFAULT ''," + + "`layout_version` int NOT NULL DEFAULT 0," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_ent_status` (`enterprise_id`,`status`),KEY `idx_status` (`status`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='年会活动'"}, + // ===== 年会域:候场 / 桌 / 座(导入/审计表由 T09/T10 追加)===== + {"event_participants", "CREATE TABLE IF NOT EXISTS `event_participants` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`event_id` bigint NOT NULL,`employee_id` bigint NOT NULL," + + "`created_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "UNIQUE KEY `uk_event_emp` (`event_id`,`employee_id`)," + + "KEY `idx_event` (`event_id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动候场名单'"}, + {"event_tables", "CREATE TABLE IF NOT EXISTS `event_tables` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`event_id` bigint NOT NULL," + + "`table_no` varchar(16) NOT NULL DEFAULT '',`name` varchar(64) NOT NULL DEFAULT ''," + + "`x` int NOT NULL DEFAULT 0,`y` int NOT NULL DEFAULT 0," + + "`capacity` int NOT NULL DEFAULT 10,`rotation` int NOT NULL DEFAULT 0," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`),KEY `idx_event` (`event_id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圆桌'"}, + {"event_seats", "CREATE TABLE IF NOT EXISTS `event_seats` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`event_id` bigint NOT NULL,`table_id` bigint NOT NULL," + + "`seat_no` int NOT NULL DEFAULT 0,`is_manual` tinyint NOT NULL DEFAULT 0," + + "`employee_id` bigint DEFAULT NULL COMMENT 'NULL=空位'," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "UNIQUE KEY `uk_evt_tbl_seat` (`event_id`,`table_id`,`seat_no`)," + + "KEY `idx_event_emp` (`event_id`,`employee_id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='座位'"}, + {"event_import_logs", "CREATE TABLE IF NOT EXISTS `event_import_logs` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`enterprise_id` bigint NOT NULL,`event_id` bigint NOT NULL DEFAULT 0," + + "`file_name` varchar(255) NOT NULL DEFAULT ''," + + "`status` varchar(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/committed/cancelled'," + + "`token` varchar(64) NOT NULL DEFAULT ''," + + "`snapshot` mediumtext COMMENT '解析行快照 JSON'," + + "`total` int NOT NULL DEFAULT 0,`inserted` int NOT NULL DEFAULT 0," + + "`updated` int NOT NULL DEFAULT 0,`skipped` int NOT NULL DEFAULT 0," + + "`conflict` int NOT NULL DEFAULT 0,`conflicts` text COMMENT '裁决结果 JSON'," + + "`operator` varchar(64) NOT NULL DEFAULT ''," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`),UNIQUE KEY `uk_token` (`token`)," + + "KEY `idx_event` (`event_id`),KEY `idx_ent` (`enterprise_id`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导入记录/审计'"}, + {"event_seat_queries", "CREATE TABLE IF NOT EXISTS `event_seat_queries` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`event_id` bigint NOT NULL," + + "`openid` varchar(64) NOT NULL DEFAULT '',`ip` varchar(64) NOT NULL DEFAULT ''," + + "`phone_masked` varchar(20) NOT NULL DEFAULT ''," + + "`result` tinyint NOT NULL DEFAULT 0 COMMENT '0失败 1成功'," + + "`created_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_evt_openid_time` (`event_id`,`openid`,`created_at`)," + + "KEY `idx_evt_ip_time` (`event_id`,`ip`,`created_at`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='查座审计与限流'"}, + // ===== 后台权限与操作审计(T17)===== + {"admin_audit_logs", "CREATE TABLE IF NOT EXISTS `admin_audit_logs` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`admin_id` bigint NOT NULL DEFAULT 0," + + "`admin_account` varchar(64) NOT NULL DEFAULT ''," + + "`admin_name` varchar(64) NOT NULL DEFAULT ''," + + "`role_value` varchar(32) NOT NULL DEFAULT ''," + + "`action` varchar(64) NOT NULL DEFAULT '' COMMENT '如 user.set_level / event.status'," + + "`target_type` varchar(32) NOT NULL DEFAULT '' COMMENT 'user/event/table/admin...'," + + "`target_id` varchar(64) NOT NULL DEFAULT ''," + + "`before_json` text COMMENT '操作前值 JSON'," + + "`after_json` text COMMENT '操作后值 JSON'," + + "`result` tinyint NOT NULL DEFAULT 1 COMMENT '0失败 1成功'," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`ip` varchar(64) NOT NULL DEFAULT ''," + + "`created_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_admin_time` (`admin_id`,`created_at`)," + + "KEY `idx_action_time` (`action`,`created_at`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台操作审计'"}, + // ===== 商业化域:优惠码 / 优惠券(追加到 migrateTables())===== + {"promo_codes", "CREATE TABLE IF NOT EXISTS `promo_codes` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`code` varchar(32) NOT NULL COMMENT '码值(唯一,大写字母+数字,排除易混字符)'," + + "`product_id` varchar(64) NOT NULL DEFAULT 'admin_1' COMMENT '绑定的支付道具 ID(须与 MP 后台逐字符一致)'," + + "`price_cents` bigint NOT NULL DEFAULT 1 COMMENT '该道具价格(分)'," + + "`scope` tinyint NOT NULL DEFAULT 0 COMMENT '0全部 1指定会员套餐 2指定次数包'," + + "`scope_keys` varchar(512) NOT NULL DEFAULT '' COMMENT '适用范围 key 的 JSON 数组'," + + "`max_uses` int NOT NULL DEFAULT 0 COMMENT '总使用上限,0=不限'," + + "`used_count` int NOT NULL DEFAULT 0 COMMENT '已用次数'," + + "`per_user_limit` int NOT NULL DEFAULT 1 COMMENT '单人限用次数'," + + "`valid_from` datetime DEFAULT NULL,`valid_to` datetime DEFAULT NULL," + + "`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用'," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`created_by` varchar(64) NOT NULL DEFAULT ''," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`),UNIQUE KEY `uk_code` (`code`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码(免单/固定价档位,独立于优惠券)'"}, + {"coupons", "CREATE TABLE IF NOT EXISTS `coupons` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`title` varchar(64) NOT NULL COMMENT '券名'," + + "`sub_title` varchar(128) NOT NULL DEFAULT '' COMMENT '副标题'," + + "`coupon_type` tinyint NOT NULL DEFAULT 2 COMMENT '1免单券(走支付) 2展示券(仅展示,默认)'," + + "`kind` tinyint NOT NULL DEFAULT 2 COMMENT '1满减 2免单(面额语义/展示)'," + + "`value` int NOT NULL DEFAULT 0 COMMENT '面额(分,仅展示用途)'," + + "`threshold_cents` int NOT NULL DEFAULT 0 COMMENT '使用门槛(分,0=无门槛)'," + + "`scope` tinyint NOT NULL DEFAULT 0 COMMENT '0全部 1指定会员套餐 2指定次数包'," + + "`scope_keys` varchar(512) NOT NULL DEFAULT ''," + + "`total_limit` int NOT NULL DEFAULT 0 COMMENT '总发行上限,0=不限'," + + "`per_user_limit` int NOT NULL DEFAULT 1 COMMENT '单人限领'," + + "`valid_from` datetime DEFAULT NULL,`valid_to` datetime DEFAULT NULL," + + "`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用'," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`),KEY `idx_type_status` (`coupon_type`,`status`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板'"}, + {"user_coupons", "CREATE TABLE IF NOT EXISTS `user_coupons` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`coupon_id` bigint NOT NULL,`user_id` bigint NOT NULL," + + "`coupon_title` varchar(64) NOT NULL DEFAULT '' COMMENT '券名快照(模板改名不影响已发券)'," + + "`coupon_type` tinyint NOT NULL DEFAULT 2 COMMENT '券型快照(模板改型不影响已发券)'," + + "`kind` tinyint NOT NULL DEFAULT 2,`value` int NOT NULL DEFAULT 0," + + "`threshold_cents` int NOT NULL DEFAULT 0," + + "`status` tinyint NOT NULL DEFAULT 0 COMMENT '0未使用 1已使用 2已过期 3已作废'," + + "`source` tinyint NOT NULL DEFAULT 1 COMMENT '1后台发放 2活动领取 3系统赠送'," + + "`granted_by` varchar(64) NOT NULL DEFAULT '' COMMENT '发放人 admin id'," + + "`granted_at` datetime DEFAULT NULL,`used_at` datetime DEFAULT NULL," + + "`expire_at` datetime DEFAULT NULL," + + "`used_order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '占用/核销对应订单号'," + + "`used_by` varchar(64) NOT NULL DEFAULT '' COMMENT 'system=订单占用;admin=人工核销'," + + "`lock_at` datetime DEFAULT NULL COMMENT '下单占用时间(超时释放依据)'," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "KEY `idx_user_status` (`user_id`,`status`)," + + "KEY `idx_coupon` (`coupon_id`)," + + "KEY `idx_user_lock` (`user_id`,`used_order_no`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户持有的优惠券(含券面快照 + 占用/核销记录)'"}, } } @@ -135,5 +326,40 @@ func migrateColumns() []migrateColumn { {"member_orders", "pack_key", "ALTER TABLE `member_orders` ADD COLUMN `pack_key` varchar(64) NOT NULL DEFAULT '' COMMENT '次数包档位'"}, {"member_orders", "tool_key", "ALTER TABLE `member_orders` ADD COLUMN `tool_key` varchar(64) NOT NULL DEFAULT '' COMMENT '次数包绑定工具'"}, {"member_orders", "times", "ALTER TABLE `member_orders` ADD COLUMN `times` int NOT NULL DEFAULT 0 COMMENT '次数包次数'"}, + + // T17:管理员启停状态(默认启用,老数据升级后立即可用) + {"admin_users", "status", "ALTER TABLE `admin_users` ADD COLUMN `status` tinyint NOT NULL DEFAULT 1 COMMENT '0停用 1启用'"}, + + // ===== T18 软删除(deleted_at / deleted_from)===== + // users:唯一键 uk_openid / uk_username,软删时墓碑化并保留原值 + {"users", "deleted_at", "ALTER TABLE `users` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"}, + {"users", "deleted_from", "ALTER TABLE `users` ADD COLUMN `deleted_from` text COMMENT '软删前唯一键原值(JSON),便于恢复'"}, + // tools:软删仅置 deleted_at(不墓碑化 tool_key,见 admin_batch.go 说明) + {"tools", "deleted_at", "ALTER TABLE `tools` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"}, + // employees:唯一键 uk_ent_phone,软删时墓碑化 phone 并保留原值 + {"employees", "deleted_at", "ALTER TABLE `employees` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"}, + {"employees", "deleted_from", "ALTER TABLE `employees` ADD COLUMN `deleted_from` text COMMENT '软删前 phone 原值(JSON),便于恢复'"}, + + // ===== member_orders 加列(追加到 migrateColumns();不加进 attach,依据约定 C4)===== + {"member_orders", "origin_price_cents", "ALTER TABLE `member_orders` ADD COLUMN `origin_price_cents` bigint NOT NULL DEFAULT 0 COMMENT '原价(分)'"}, + {"member_orders", "paid_price_cents", "ALTER TABLE `member_orders` ADD COLUMN `paid_price_cents` bigint NOT NULL DEFAULT 0 COMMENT '实付(分)'"}, + {"member_orders", "promo_code_id", "ALTER TABLE `member_orders` ADD COLUMN `promo_code_id` bigint NOT NULL DEFAULT 0 COMMENT '使用的优惠码 id,0=未使用'"}, + {"member_orders", "promo_code", "ALTER TABLE `member_orders` ADD COLUMN `promo_code` varchar(32) NOT NULL DEFAULT '' COMMENT '优惠码值快照'"}, + {"member_orders", "user_coupon_id", "ALTER TABLE `member_orders` ADD COLUMN `user_coupon_id` bigint NOT NULL DEFAULT 0 COMMENT '使用的免单券 user_coupons.id,0=未使用'"}, + {"member_orders", "discount_cents", "ALTER TABLE `member_orders` ADD COLUMN `discount_cents` bigint NOT NULL DEFAULT 0 COMMENT '优惠金额(分)'"}, + {"member_orders", "promo_kind", "ALTER TABLE `member_orders` ADD COLUMN `promo_kind` tinyint NOT NULL DEFAULT 0 COMMENT '使用凭证类型:0无 1优惠码 2免单券'"}, + + // ===== 订单退款/回收所购服务(退款能力)===== + // service_revoked:退款时是否已回收所购服务(用于列表展示 / 审计留痕 / 幂等判重)。 + // refunded_at :退款时间(列表展示「退款时间」)。 + {"member_orders", "service_revoked", "ALTER TABLE `member_orders` ADD COLUMN `service_revoked` tinyint NOT NULL DEFAULT 0 COMMENT '退款时是否已回收所购服务:0未回收 1已回收'"}, + {"member_orders", "refunded_at", "ALTER TABLE `member_orders` ADD COLUMN `refunded_at` datetime DEFAULT NULL COMMENT '退款时间'"}, + + // ===== T04 座次:桌型 / 备注 / 排除(N4)===== + // 全部幂等加列;老活动无这些属性时回退默认值(普通桌 / 空备注 / 参与),保证老画布与自动排座不报错(S14)。 + {"event_tables", "table_type", "ALTER TABLE `event_tables` ADD COLUMN `table_type` tinyint NOT NULL DEFAULT 0 COMMENT '桌型:0普通 1主桌 2签到台 3媒体席 4备用桌 9其他'"}, + {"event_tables", "remark", "ALTER TABLE `event_tables` ADD COLUMN `remark` varchar(64) NOT NULL DEFAULT '' COMMENT '桌备注'"}, + {"event_tables", "exclude_auto", "ALTER TABLE `event_tables` ADD COLUMN `exclude_auto` tinyint NOT NULL DEFAULT 0 COMMENT '1=不参与自动排序'"}, + {"event_participants", "exclude_assign", "ALTER TABLE `event_participants` ADD COLUMN `exclude_assign` tinyint NOT NULL DEFAULT 0 COMMENT '1=不参与排座'"}, } } diff --git a/internal/logic/note.go b/internal/logic/note.go new file mode 100644 index 0000000..249173e --- /dev/null +++ b/internal/logic/note.go @@ -0,0 +1,471 @@ +package logic + +import ( + "context" + "fmt" + "strings" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/user/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 笔记域 +// +// 额度规则(PRD-02 §4.1.1,与既有三池额度体系「不混用」): +// - 免费用户活跃笔记数(deleted_at IS NULL)≤ 50; +// - 会员不限量; +// - 新建计入、编辑不计入、删除(软删)立即释放额度; +// - 会员判定一律走 EffectiveLevelKey,保证「会员过期即回落免费额度」。 +// +// 与「50 是总量还是每月」的结论:总量上限。笔记是内容资产而非使用次数, +// 故不设周期、不重置(与 quota.go 的按周期扣减刻意区分)。 +// ============================================================================ + +// ===== 自定义错误码(沿用 gcodeQuotaExhausted 范式)===== + +func gcodeNoteLimit() gcode.Code { + return gcode.New(consts.CodeNoteLimit, "", nil) +} + +func gcodeNoteNotFound() gcode.Code { + return gcode.New(consts.CodeNoteNotFound, "", nil) +} + +// ===== 额度判定(纯函数,便于单测覆盖边界)===== + +// evaluateNoteQuota 根据「活跃笔记数 + 是否会员」计算额度展示与是否允许新建。 +// +// 返回: +// - limit 免费上限(会员为 0,表示不限) +// - unlimited 是否不限量 +// - canCreate 当前是否允许新建/恢复 +// +// 设计要点:会员过期且活跃数 > 50 时,canCreate=false(仅禁止新建), +// 已存在的笔记保留不动(PRD-02 R6-4 / Q8)。 +func evaluateNoteQuota(activeCount int, isMember bool) (limit int, unlimited bool, canCreate bool) { + if isMember { + return 0, true, true + } + return consts.NoteFreeLimit, false, activeCount < consts.NoteFreeLimit +} + +// activeNoteCount 统计用户活跃笔记数(未软删除) +func activeNoteCount(ctx context.Context, userId int64) (int, error) { + return g.Model(consts.TableNotes). + Where("user_id", userId). + WhereNull("deleted_at"). + Count() +} + +// noteUser 取当前登录用户并计算会员态。会员判定唯一入口 = EffectiveLevelKey。 +func noteUser(ctx context.Context, userId int64) (*entity.Users, bool, error) { + user, err := getUserById(ctx, userId) + if err != nil { + return nil, false, err + } + isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey + return user, isMember, nil +} + +// ===== 列表 ===== + +// NoteList 笔记列表(分页 / 关键词搜索 / 标签筛选),附带额度计数。 +func NoteList(ctx context.Context, req *v1.NoteListReq) (*v1.NoteListRes, error) { + userId := CtxUserId(ctx) + _, isMember, err := noteUser(ctx, userId) + if err != nil { + return nil, err + } + activeCount, err := activeNoteCount(ctx, userId) + if err != nil { + return nil, err + } + limit, _, _ := evaluateNoteQuota(activeCount, isMember) + + model := g.Model(consts.TableNotes). + Where("user_id", userId). + WhereNull("deleted_at") + if kw := strings.TrimSpace(req.Keyword); kw != "" { + like := "%" + kw + "%" + model = model.Where("(title LIKE ? OR content LIKE ?)", like, like) + } + if tag := strings.TrimSpace(req.Tag); tag != "" { + // tags 为逗号分隔串,FIND_IN_SET 精确匹配单个标签 + model = model.Where("FIND_IN_SET(?, tags)", tag) + } + + total, err := model.Count() + if err != nil { + return nil, err + } + page, size := normalizeNotePage(req.Page, req.PageSize) + records, err := model.OrderDesc("is_pinned").OrderDesc("updated_at").Page(page, size).All() + if err != nil { + return nil, err + } + + list := make([]v1.NoteOut, 0, len(records)) + for _, r := range records { + note := &entity.Notes{} + if err = r.Struct(note); err != nil { + continue + } + list = append(list, noteOutFromEntity(note)) + } + return &v1.NoteListRes{ + List: list, + Total: total, + ActiveCount: activeCount, + Limit: limit, + IsMember: isMember, + }, nil +} + +// ===== 详情 ===== + +// NoteDetail 单条笔记详情(仅本人、且未删除)。 +func NoteDetail(ctx context.Context, req *v1.NoteDetailReq) (*v1.NoteDetailRes, error) { + userId := CtxUserId(ctx) + note, err := findActiveNote(ctx, userId, req.Id) + if err != nil { + return nil, err + } + res := &v1.NoteDetailRes{ + Id: note.Id, + Title: note.Title, + Content: note.Content, + Tags: splitNoteTags(note.Tags), + IsPinned: note.IsPinned == 1, + RemindStatus: note.RemindStatus, + } + if note.RemindAt != nil && !note.RemindAt.IsZero() { + res.RemindAt = note.RemindAt.Format("Y-m-d H:i:s") + } + return res, nil +} + +// ===== 新建 / 编辑 ===== + +// SaveNote 新建(Id=0)或编辑(Id>0)笔记。 +// 新建计入额度校验;编辑不计入额度(PRD-02 R6-5)。 +func SaveNote(ctx context.Context, req *v1.NoteSaveReq) (*v1.NoteSaveRes, error) { + userId := CtxUserId(ctx) + _, isMember, err := noteUser(ctx, userId) + if err != nil { + return nil, err + } + remindAt, err := parseNoteRemind(req.RemindAt) + if err != nil { + return nil, err + } + now := gtime.Now() + remindStatus := noteRemindStatusOf(remindAt, now) + tags := joinNoteTags(req.Tags) + pinned := 0 + if req.IsPinned { + pinned = 1 + } + + // 编辑既有笔记:不占用额度 + if req.Id > 0 { + data := g.Map{ + "title": req.Title, + "content": req.Content, + "tags": tags, + "is_pinned": pinned, + "updated_at": now, + "remind_at": remindAt, + "remind_status": remindStatus, + } + if remindAt == nil { + // 取消提醒:清空发送时间 + data["remind_sent_at"] = nil + } + result, err := g.Model(consts.TableNotes). + Where("id", req.Id). + Where("user_id", userId). + WhereNull("deleted_at"). + Data(data). + Update() + if err != nil { + return nil, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") + } + activeCount, err := activeNoteCount(ctx, userId) + if err != nil { + return nil, err + } + return &v1.NoteSaveRes{Id: req.Id, ActiveCount: activeCount}, nil + } + + // 新建:先校验额度 + activeCount, err := activeNoteCount(ctx, userId) + if err != nil { + return nil, err + } + if _, _, canCreate := evaluateNoteQuota(activeCount, isMember); !canCreate { + return nil, gerror.NewCode(gcodeNoteLimit(), noteLimitMessage()) + } + id, err := g.Model(consts.TableNotes).Data(g.Map{ + "user_id": userId, + "title": req.Title, + "content": req.Content, + "tags": tags, + "is_pinned": pinned, + "remind_at": remindAt, + "remind_status": remindStatus, + "remind_sent_at": nil, + "remind_attempts": 0, + "subscribe_count": 0, + "created_at": now, + "updated_at": now, + }).InsertAndGetId() + if err != nil { + return nil, err + } + return &v1.NoteSaveRes{Id: id, ActiveCount: activeCount + 1}, nil +} + +// ===== 删除(软删,释放额度)===== + +// DeleteNote 软删除笔记,deleted_at 置位后立即不再计入活跃数与额度。 +func DeleteNote(ctx context.Context, req *v1.NoteDeleteReq) (*v1.NoteDeleteRes, error) { + userId := CtxUserId(ctx) + now := gtime.Now() + result, err := g.Model(consts.TableNotes). + Where("id", req.Id). + Where("user_id", userId). + WhereNull("deleted_at"). + Data(g.Map{"deleted_at": now, "updated_at": now}). + Update() + if err != nil { + return nil, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") + } + return &v1.NoteDeleteRes{}, nil +} + +// ===== 恢复(P2,回收站)===== + +// RestoreNote 从回收站恢复;恢复计入额度,超过上限则提示先清理(PRD-02 R6-14)。 +func RestoreNote(ctx context.Context, req *v1.NoteRestoreReq) (*v1.NoteRestoreRes, error) { + userId := CtxUserId(ctx) + _, isMember, err := noteUser(ctx, userId) + if err != nil { + return nil, err + } + records, err := g.Model(consts.TableNotes). + Where("id", req.Id). + Where("user_id", userId). + WhereNotNull("deleted_at"). + Limit(1). + All() + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") + } + + activeCount, err := activeNoteCount(ctx, userId) + if err != nil { + return nil, err + } + if _, _, canCreate := evaluateNoteQuota(activeCount, isMember); !canCreate { + return nil, gerror.NewCode(gcodeNoteLimit(), "免费版最多 "+itoa(consts.NoteFreeLimit)+" 条,请先清理后再恢复") + } + now := gtime.Now() + if _, err = g.Model(consts.TableNotes). + Where("id", req.Id). + Where("user_id", userId). + Data(g.Map{"deleted_at": nil, "updated_at": now}). + Update(); err != nil { + return nil, err + } + return &v1.NoteSaveRes{Id: req.Id, ActiveCount: activeCount + 1}, nil +} + +// ===== 取消提醒 ===== + +// CancelRemind 清除某条笔记的提醒设置。 +func CancelRemind(ctx context.Context, req *v1.NoteRemindCancelReq) (*v1.NoteRemindCancelRes, error) { + userId := CtxUserId(ctx) + result, err := g.Model(consts.TableNotes). + Where("id", req.Id). + Where("user_id", userId). + WhereNull("deleted_at"). + Data(g.Map{ + "remind_at": nil, + "remind_status": consts.NoteRemindNone, + "remind_sent_at": nil, + "updated_at": gtime.Now(), + }). + Update() + if err != nil { + return nil, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") + } + return &v1.NoteRemindCancelRes{}, nil +} + +// ===== 订阅授权上报 ===== + +// ReportSubscribe 累加笔记的订阅授权可发送次数(前端 requestSubscribeMessage 成功后调用)。 +func ReportSubscribe(ctx context.Context, req *v1.NoteSubscribeReportReq) (*v1.NoteSubscribeReportRes, error) { + userId := CtxUserId(ctx) + result, err := g.Model(consts.TableNotes). + Where("id", req.NoteId). + Where("user_id", userId). + WhereNull("deleted_at"). + Increment("subscribe_count", req.Count) + if err != nil { + return nil, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") + } + return &v1.NoteSubscribeReportRes{}, nil +} + +// ===== 内部辅助 ===== + +// findActiveNote 读取本人未删除笔记;不存在或无权时返回 4004。 +func findActiveNote(ctx context.Context, userId, id int64) (*entity.Notes, error) { + record, err := g.Model(consts.TableNotes). + Where("id", id). + Where("user_id", userId). + WhereNull("deleted_at"). + One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") + } + note := &entity.Notes{} + if err = record.Struct(note); err != nil { + return nil, err + } + return note, nil +} + +func noteOutFromEntity(note *entity.Notes) v1.NoteOut { + out := v1.NoteOut{ + Id: note.Id, + Title: note.Title, + Summary: noteSummary(note.Content, 80), + Tags: splitNoteTags(note.Tags), + IsPinned: note.IsPinned == 1, + RemindStatus: note.RemindStatus, + } + if note.RemindAt != nil && !note.RemindAt.IsZero() { + out.RemindAt = note.RemindAt.Format("Y-m-d H:i:s") + } + if note.UpdatedAt != nil && !note.UpdatedAt.IsZero() { + out.UpdatedAt = note.UpdatedAt.Format("Y-m-d H:i:s") + } + return out +} + +// parseNoteRemind 解析提醒时间字符串;空串表示不设/取消提醒。 +func parseNoteRemind(s string) (*gtime.Time, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + t, err := gtime.StrToTime(s) + if err != nil { + return nil, gerror.NewCode(gcode.CodeValidationFailed, "提醒时间格式不正确") + } + return t, nil +} + +// noteRemindStatusOf 依据提醒时间与当前时间决定初始提醒状态: +// 未来时间 → 待提醒(1);过去时间 → 已过期(4);无提醒 → 未设置(0)。 +func noteRemindStatusOf(remindAt, now *gtime.Time) int { + if remindAt == nil || remindAt.IsZero() { + return consts.NoteRemindNone + } + if remindAt.After(now) { + return consts.NoteRemindPending + } + return consts.NoteRemindExpired +} + +// joinNoteTags 规整标签:去空白、去空项、去重,逗号拼接,超长截断(varchar(255))。 +func joinNoteTags(tags []string) string { + seen := make(map[string]bool, len(tags)) + out := make([]string, 0, len(tags)) + for _, t := range tags { + t = strings.TrimSpace(t) + if t == "" || seen[t] { + continue + } + seen[t] = true + out = append(out, t) + } + csv := strings.Join(out, ",") + if rs := []rune(csv); len(rs) > 255 { + csv = string(rs[:255]) + } + return csv +} + +// splitNoteTags 拆分逗号分隔标签串 +func splitNoteTags(csv string) []string { + out := make([]string, 0) + for _, t := range strings.Split(csv, ",") { + if t = strings.TrimSpace(t); t != "" { + out = append(out, t) + } + } + return out +} + +// noteSummary 生成正文摘要(列表不返回全文),单行化并截断。 +func noteSummary(content string, max int) string { + s := strings.ReplaceAll(content, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.TrimSpace(s) + rs := []rune(s) + if len(rs) > max { + return string(rs[:max]) + "…" + } + return s +} + +// normalizeNotePage 分页兜底:page≥1,1≤pageSize≤100。 +func normalizeNotePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size <= 0 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} + +func noteLimitMessage() string { + return "免费版最多 " + itoa(consts.NoteFreeLimit) + " 条,开通会员可不限量" +} + +// itoa 小整数转字符串(避免为一个格式化再引 strconv 到多处) +func itoa(v int) string { + return fmt.Sprintf("%d", v) +} diff --git a/internal/logic/note_test.go b/internal/logic/note_test.go new file mode 100644 index 0000000..fa2b594 --- /dev/null +++ b/internal/logic/note_test.go @@ -0,0 +1,107 @@ +package logic + +import ( + "testing" + "time" + + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// TestEvaluateNoteQuota 覆盖额度边界: +// - 免费未满(49) → 允许新建 +// - 免费刚好满(50) → 禁止新建(对应「第 51 条返回 4003」的前置条件) +// - 会员过期回落且活跃数超限(51) → 仅禁止新建,不删数据 +// - 会员(不限量) → 允许新建 +// +// 该函数是 SaveNote/RestoreNote 真实调用的额度判定,非复述逻辑。 +func TestEvaluateNoteQuota(t *testing.T) { + cases := []struct { + name string + activeCount int + isMember bool + wantLimit int + wantUnlimit bool + wantCreate bool + }{ + {"免费_未满", 49, false, consts.NoteFreeLimit, false, true}, + {"免费_刚好满", 50, false, consts.NoteFreeLimit, false, false}, + {"会员过期回落_超限仅禁新建", 51, false, consts.NoteFreeLimit, false, false}, + {"会员_超限仍不限", 51, true, 0, true, true}, + {"会员_零条", 0, true, 0, true, true}, + } + for _, c := range cases { + limit, unlimited, canCreate := evaluateNoteQuota(c.activeCount, c.isMember) + if limit != c.wantLimit || unlimited != c.wantUnlimit || canCreate != c.wantCreate { + t.Fatalf("%s: got(limit=%d, unlimited=%v, canCreate=%v) want(%d, %v, %v)", + c.name, limit, unlimited, canCreate, c.wantLimit, c.wantUnlimit, c.wantCreate) + } + } +} + +// TestEffectiveLevelKeyMemberFallback 证明会员判定确实走 EffectiveLevelKey, +// 且会员到期后回落默认等级(保证「会员过期即回落免费额度」)。 +func TestEffectiveLevelKeyMemberFallback(t *testing.T) { + if got := EffectiveLevelKey(nil); got != consts.DefaultLevelKey { + t.Fatalf("nil user: got %s want %s", got, consts.DefaultLevelKey) + } + // 永久会员(level_expire_at 为空) + if got := EffectiveLevelKey(&entity.Users{LevelKey: "v2"}); got != "v2" { + t.Fatalf("永久会员: got %s want v2", got) + } + // 未过期会员 + if got := EffectiveLevelKey(&entity.Users{LevelKey: "v2", LevelExpireAt: gtime.Now().Add(time.Hour)}); got != "v2" { + t.Fatalf("未过期会员: got %s want v2", got) + } + // 已过期会员 → 回落默认(免费)等级 + if got := EffectiveLevelKey(&entity.Users{LevelKey: "v2", LevelExpireAt: gtime.Now().Add(-time.Hour)}); got != consts.DefaultLevelKey { + t.Fatalf("已过期会员: got %s want %s", got, consts.DefaultLevelKey) + } +} + +// TestSendNoteRemindWithEmptyTemplateDegrades 覆盖「模板未配置时接口正常、无 panic」: +// 直接调用真实发送函数并传入空模板 ID,断言不 panic 且返回 nil(降级)。 +func TestSendNoteRemindWithEmptyTemplateDegrades(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("模板未配置时不应 panic,但发生了 panic: %v", r) + } + }() + ctx := gctx.New() + err := sendNoteRemindWithTemplate(ctx, NoteRemindSendReq{ + NoteId: 1, + Openid: "o_test_openid", + Title: "会议要点", + Content: "待办清单:a、b、c", + Time: "2026-09-20 09:00:00", + }, "") + if err != nil { + t.Fatalf("模板未配置时应降级返回 nil,但返回 err=%v", err) + } +} + +// TestShouldClaimRemind 证明「同一提醒只发一次」的条件更新语义: +// 只有当抢占了 remind_status=1→2 的那一次(rowsAffected==1)才继续发送。 +func TestShouldClaimRemind(t *testing.T) { + if !shouldClaimRemind(1) { + t.Fatalf("rowsAffected=1 时应继续发送") + } + if shouldClaimRemind(0) { + t.Fatalf("rowsAffected=0 时应跳过(已被其它实例/轮次抢占)") + } +} + +// TestJoinAndSplitTags 覆盖标签规整(去空白/去空项/去重)与回拆分。 +func TestJoinAndSplitTags(t *testing.T) { + csv := joinNoteTags([]string{" 工作 ", "", "待办", "工作"}) + if csv != "工作,待办" { + t.Fatalf("joinNoteTags got %q want %q", csv, "工作,待办") + } + tags := splitNoteTags(csv) + if len(tags) != 2 || tags[0] != "工作" || tags[1] != "待办" { + t.Fatalf("splitNoteTags got %v want [工作 待办]", tags) + } +} diff --git a/internal/logic/order_pay.go b/internal/logic/order_pay.go new file mode 100644 index 0000000..3016dc9 --- /dev/null +++ b/internal/logic/order_pay.go @@ -0,0 +1,679 @@ +package logic + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/user/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 年会 / 会员订单域 · 两步式下单(N1) +// +// 依据:增量架构 §2.1(C-1~C-6)、§2.5(D2)、§5.1 图①、§5.3 图③。 +// +// 拆链路: +// createOrder —— 只建单(/member/order、/quota/order),同商品待支付单复用(N1-10) +// buildPayParams —— 只建签名(/member/order/pay、/quota/order/pay),此处才校验「支付人=登录人」 +// applyVoucherToOrder —— 应用/更换/清除优惠(裁定 D3:应用时在事务内占用) +// ClosePendingOrders —— 超时待支付单关闭(gcron),与券占用释放 TTL 同源(D2) +// +// 发货链路(deliverOrder / PayNotifyHandle)**一行不动**:发货只认本地订单行(S4)。 +// ============================================================================ + +// orderStatusCode 4015:订单不存在 / 不属于当前用户 / 状态不允许该操作。 +func gcodeOrderInvalid() gcode.Code { + return gcode.New(consts.CodeOrderInvalid, "", nil) +} + +func errOrderInvalid(msg string) error { + return gerror.NewCode(gcodeOrderInvalid(), msg) +} + +// orderPendingTtlSeconds 待支付订单关闭时限(秒)。 +// +// 裁定 D2:与免单券占用释放 TTL **同源**(统一读 settings 键 coupon.lock.ttlSeconds,默认 900)—— +// 单一来源、物理上不可不一致;本轮不新增 settings 键。 +func orderPendingTtlSeconds(ctx context.Context) int { + return couponLockTtlSeconds(ctx) +} + +// orderSnapshot 订单行快照(含优惠凭证 / 价格列)。 +// +// 说明:entity.MemberOrders 未承载本轮新增的 promo_* / *_price_cents 列,且这些列在老订单上 +// 可能为默认 0/空 → 统一在这里按「读回 0 / 空串」处理,展示层再按「原价 = 实付」兜底(S14)。 +type orderSnapshot struct { + Id int64 + OutTradeNo string + UserId int64 + Openid string + OrderType int + PlanKey string + PackKey string + ToolKey string + Times int + ProductId string + PriceCents int64 + OriginPriceCents int64 + PaidPriceCents int64 + DiscountCents int64 + PromoKind int + PromoCodeId int64 + PromoCode string + UserCouponId int64 + LevelKey string + DurationDays int + Status int + PayChannel string + Attach string + CreatedAt *gtime.Time + DeliveredAt *gtime.Time +} + +func orderSnapshotFromRecord(r gdb.Record) *orderSnapshot { + return &orderSnapshot{ + Id: r["id"].Int64(), + OutTradeNo: r["out_trade_no"].String(), + UserId: r["user_id"].Int64(), + Openid: r["openid"].String(), + OrderType: r["order_type"].Int(), + PlanKey: r["plan_key"].String(), + PackKey: r["pack_key"].String(), + ToolKey: r["tool_key"].String(), + Times: r["times"].Int(), + ProductId: r["product_id"].String(), + PriceCents: r["price_cents"].Int64(), + OriginPriceCents: r["origin_price_cents"].Int64(), + PaidPriceCents: r["paid_price_cents"].Int64(), + DiscountCents: r["discount_cents"].Int64(), + PromoKind: r["promo_kind"].Int(), + PromoCodeId: r["promo_code_id"].Int64(), + PromoCode: r["promo_code"].String(), + UserCouponId: r["user_coupon_id"].Int64(), + LevelKey: r["level_key"].String(), + DurationDays: r["duration_days"].Int(), + Status: r["status"].Int(), + PayChannel: r["pay_channel"].String(), + Attach: r["attach"].String(), + CreatedAt: r["created_at"].GTime(), + DeliveredAt: r["delivered_at"].GTime(), + } +} + +// mustOwnOrder 按单号取「属于当前用户」的订单;不存在 / 非本人 → 4015(防枚举,两者文案统一)。 +func mustOwnOrder(ctx context.Context, userId int64, outTradeNo string) (*orderSnapshot, error) { + record, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, errOrderInvalid("订单不存在") + } + o := orderSnapshotFromRecord(record) + if o.UserId != userId { + return nil, errOrderInvalid("订单不存在或不属于当前用户") + } + return o, nil +} + +// orderTargetKey 订单的目标 key:会员 = plan_key;次数包 = pack_key。 +func orderTargetKey(o *orderSnapshot) string { + if o.OrderType == consts.OrderTypeQuota { + return o.PackKey + } + return o.PlanKey +} + +// ============================================================================ +// 建单(只建单 + 复用) +// ============================================================================ + +// createOrderResult 建单结果(复用标记 + 价格快照)。 +type createOrderResult struct { + OutTradeNo string + Reused bool + OriginPriceCents int64 + PaidPriceCents int64 + Status int +} + +// createOrder 建单 / 复用(N1-1/2/10)。 +// +// 复用规则:同一用户 + 同商品(reuseWhere 指定,会员=plan_key;次数包=pack_key+tool_key)+ +// 状态 = 待支付 的订单存在 → 直接复用(reused=true),保证「连续 POST N 次待支付单恒为 1 条」。 +// +// 若命中的待支付单已超时(created_at < now - ttl,ttl 与订单关闭同源 D2)→ 先关闭再新建, +// 避免复用一笔「即将被 gcron 关闭」的僵尸单。 +func createOrder( + ctx context.Context, userId int64, orderType int, productId string, priceCents int64, + reuseWhere, orderFields g.Map, +) (*createOrderResult, error) { + user, err := getUserById(ctx, userId) + if err != nil { + return nil, err + } + + deadline := gtime.Now().Add(-time.Duration(orderPendingTtlSeconds(ctx)) * time.Second) + + base := g.Model(consts.TableMemberOrders). + Where("user_id", userId). + Where("order_type", orderType). + Where("status", consts.OrderStatusPending) + for k, v := range reuseWhere { + base = base.Where(k, v) + } + + // 复用:同键的待支付单仍「新鲜」(created_at 在 TTL 内)。 + // + // ⚠️ 新鲜度必须在 **SQL 侧** 判定,不能在 Go 侧用 readback.After(now): + // 本库 DSN 未指定 loc → GoFrame 写入 time.Time 时按驱动默认(UTC)落库, + // 而 **读回** 的时间串被 gvar.GTime() 按 **本地** 重新解释,导致 readback 比 + // 真实时刻早一个时区偏移(本机 +08 → 8 小时)。实测刚建的单 readback=02:06, + // 而 deadline=09:51 → After() 恒为 false,会把新单误判为超时并重复建单。 + // WHERE 绑定的 gtime 与 INSERT 走 **同一** 时区转换,故 SQL 侧比较是自洽的 + // (与 ReleaseExpiredCouponLocks 的 `lock_at < ?` 判定同源)。 + reusable, err := base.Clone().WhereGT("created_at", deadline).OrderDesc("id").One() + if err != nil { + return nil, err + } + if !reusable.IsEmpty() { + return &createOrderResult{ + OutTradeNo: reusable["out_trade_no"].String(), + Reused: true, + OriginPriceCents: reusable["origin_price_cents"].Int64(), + PaidPriceCents: reusable["paid_price_cents"].Int64(), + Status: reusable["status"].Int(), + }, nil + } + + // 关闭同键的所有非新鲜待支付单(created_at IS NULL 亦视为过期),保持「恒为 1 条」。 + stale, err := base.Clone().Where("created_at IS NULL OR created_at < ?", deadline).All() + if err != nil { + return nil, err + } + for _, r := range stale { + if err = closeOrder(ctx, r["out_trade_no"].String()); err != nil { + return nil, err + } + } + + outTradeNo, err := newOutTradeNo() + if err != nil { + return nil, err + } + attach := g.Map{"userId": userId} + for k, v := range orderFields { + attach[k] = v + } + attachBytes, _ := json.Marshal(attach) + + now := gtime.Now() + order := g.Map{ + "out_trade_no": outTradeNo, + "wx_order_id": nil, + "user_id": userId, + "openid": user.Openid, // 建单时尽力写入;支付时会刷新为最新 + "product_id": productId, + "price_cents": priceCents, + "origin_price_cents": priceCents, + "paid_price_cents": priceCents, + "discount_cents": 0, + "promo_kind": consts.PromoKindNone, + "status": consts.OrderStatusPending, + "pay_channel": consts.PayChannelWx, + "attach": string(attachBytes), + "created_at": now, + "updated_at": now, + } + for k, v := range orderFields { + order[k] = v + } + // plan_key / level_key 是会员专有列且 NOT NULL 无默认值(次数包订单无此语义)→ 补齐空串, + // 避免 Error 1364;不把库列改成 DEFAULT ''(level_key 被静默填空会导致发货时无声降级)。 + for _, k := range []string{"plan_key", "level_key"} { + if _, ok := order[k]; !ok { + order[k] = "" + } + } + if _, err = g.Model(consts.TableMemberOrders).Data(order).Insert(); err != nil { + return nil, err + } + g.Log().Infof(ctx, "[order] 建单 outTradeNo=%s userId=%d type=%d productId=%s origin=%d", + outTradeNo, userId, orderType, productId, priceCents) + return &createOrderResult{ + OutTradeNo: outTradeNo, + Reused: false, + OriginPriceCents: priceCents, + PaidPriceCents: priceCents, + Status: consts.OrderStatusPending, + }, nil +} + +// ============================================================================ +// 建签名(只建签名) +// ============================================================================ + +// buildPayParams 对已建待支付订单构建支付参数(N1-5 / O2 / O6)。 +// +// 步骤:状态校验(已发货/已关闭/已退款 → 4015)→ 复核订单占用凭证仍有效(失效则回退原价并返回 4011/4010) +// → 刷新 session_key 并校验「支付人 = 登录人」→ 持久化 openid → 以「订单行的 product_id + paid_price_cents」签名。 +func buildPayParams(ctx context.Context, userId int64, outTradeNo, code string) (*v1.MemberOrderPayRes, error) { + cfg := xpayConfig(ctx) + if !cfg.Configured() { + return nil, gerror.NewCode(gcodePayFail(), + "虚拟支付尚未配置,请在 MP 后台【虚拟支付 → 基本配置】获取 OfferID 与现网 AppKey 后填入 config.yaml") + } + + order, err := mustOwnOrder(ctx, userId, outTradeNo) + if err != nil { + return nil, err + } + if order.Status != consts.OrderStatusPending { + return nil, errOrderInvalid("订单状态不允许支付") + } + + // 复核凭证:失效 → 回退订单到原价并释放占用(在独立事务内提交),随后返回 4011 / 4010。 + if err = recheckVoucherOnPay(ctx, userId, outTradeNo); err != nil { + return nil, err + } + + // 换 session_key(signature 的密钥)并校验支付人与登录人一致。 + openid, err := RefreshSessionKey(ctx, userId, code) + if err != nil { + return nil, err + } + user, err := getUserById(ctx, userId) + if err != nil { + return nil, err + } + if user.Openid != "" && openid != "" && openid != user.Openid { + return nil, gerror.NewCode(gcodePayFail(), "登录态与支付账号不一致,请重新进入小程序后再试") + } + if openid == "" { + return nil, gerror.NewCode(gcodePayFail(), "无法确定支付账号,请重新进入小程序后再试") + } + latest, err := getUserById(ctx, userId) + if err != nil { + return nil, err + } + if latest.SessionKey == "" { + return nil, gerror.NewCode(gcodePayFail(), "登录态已失效,请重新进入小程序后再试") + } + + // 复核可能已回退价格 → 重新读订单取最新 product_id / paid_price_cents。 + order, err = mustOwnOrder(ctx, userId, outTradeNo) + if err != nil { + return nil, err + } + + // 持久化 openid:发货推送 / 主动查单都需要 openid(建单时可能为空)。 + if _, err = g.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo). + Data(g.Map{"openid": openid, "updated_at": gtime.Now()}).Update(); err != nil { + return nil, err + } + + signData, err := BuildSignData(cfg.OfferId, order.ProductId, order.PaidPriceCents, outTradeNo, order.Attach, cfg.Env) + if err != nil { + return nil, err + } + g.Log().Infof(ctx, "[xpay] 建签名 outTradeNo=%s productId=%s paid=%d offerId=%s env=%d", + outTradeNo, order.ProductId, order.PaidPriceCents, cfg.OfferId, cfg.Env) + return &v1.MemberOrderPayRes{ + OutTradeNo: outTradeNo, + Mode: consts.XPayMode, + SignData: signData, + PaySig: CalcPaySig(xpayURIPayRequest, signData, cfg.AppKey), + Signature: CalcSignature(signData, latest.SessionKey), + Env: cfg.Env, + }, nil +} + +// MemberOrderPay 会员订单建签名(O2) +func MemberOrderPay(ctx context.Context, outTradeNo, code string) (*v1.MemberOrderPayRes, error) { + return buildPayParams(ctx, CtxUserId(ctx), outTradeNo, code) +} + +// QuotaOrderPay 次数包订单建签名(O6,与会员共用同一下签名链路) +func QuotaOrderPay(ctx context.Context, outTradeNo, code string) (*v1.QuotaOrderPayRes, error) { + return buildPayParams(ctx, CtxUserId(ctx), outTradeNo, code) +} + +// ============================================================================ +// 支付前凭证复核 +// ============================================================================ + +// recheckVoucherOnPay 支付前复核订单占用凭证仍有效(N1-5 / §5.1 ③)。 +// +// 若失效:在**独立事务内**释放占用并把订单回退到原价(必须提交,否则等于没回退), +// 事务成功后返回 4011(券)/ 4010(码),要求用户重新应用。 +func recheckVoucherOnPay(ctx context.Context, userId int64, outTradeNo string) error { + var fail error + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, err := tx.Model(consts.TableMemberOrders). + Where("out_trade_no", outTradeNo).Where("user_id", userId).LockUpdate().One() + if err != nil { + return err + } + if row.IsEmpty() { + return errOrderInvalid("订单不存在或不属于当前用户") + } + o := orderSnapshotFromRecord(row) + if o.Status != consts.OrderStatusPending { + return errOrderInvalid("订单状态不允许支付") + } + if o.PromoKind == consts.PromoKindNone { + return nil + } + reason, rerr := voucherStillValidTx(ctx, tx, o) + if rerr != nil { + return rerr + } + if reason == "" { + return nil + } + // 失效 → 释放 + 回退原价(本事务内完成并提交) + if err = rollbackVoucherTx(ctx, tx, o); err != nil { + return err + } + if o.PromoKind == consts.PromoKindCoupon { + fail = errCouponUnavailable(reason) + } else { + fail = errPromoInvalid(reason) + } + return nil + }) + if err != nil { + return err + } + return fail +} + +// voucherStillValidTx 复核订单占用凭证是否仍有效;有效返回空串,失效返回可读原因。 +// +// 过期判定一律在 **SQL 侧**(绑定 gtime 参数,与写入同源),不在 Go 侧比较读回时间(TZ-01)。 +func voucherStillValidTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) (string, error) { + switch o.PromoKind { + case consts.PromoKindCoupon: + row, err := tx.Model(consts.TableUserCoupons).Where("id", o.UserCouponId).One() + if err != nil { + return "", err + } + if row.IsEmpty() { + return "优惠券已失效,请重新选择", nil + } + c := &entity.UserCoupons{} + if err = row.Struct(c); err != nil { + return "", err + } + if c.UserId != o.UserId || c.Status != consts.UserCouponStatusUsed || c.UsedOrderNo != o.OutTradeNo { + return "优惠券已失效,请重新选择", nil + } + expired, err := couponExpiredAt(ctx, tx.Model(consts.TableUserCoupons), c.Id, gtime.Now()) + if err != nil { + return "", err + } + if expired { + return "优惠券已过期,请重新选择", nil + } + return "", nil + case consts.PromoKindCode: + row, err := tx.Model(consts.TablePromoCodes).Where("id", o.PromoCodeId).One() + if err != nil { + return "", err + } + if row.IsEmpty() { + return "优惠码已失效,请重新应用", nil + } + p := &entity.PromoCodes{} + if err = row.Struct(p); err != nil { + return "", err + } + if p.Status != 1 { + return "优惠码已失效,请重新应用", nil + } + expiredCnt, err := tx.Model(consts.TablePromoCodes). + Where("id", p.Id). + Where("valid_to IS NOT NULL AND valid_to < ?", gtime.Now()). + Count() + if err != nil { + return "", err + } + if expiredCnt > 0 { + return "优惠码已过期,请重新应用", nil + } + return "", nil + default: + return "优惠凭证异常,请重新应用", nil + } +} + +// ============================================================================ +// 应用 / 更换 / 清除优惠(裁定 D3:应用时占用) +// ============================================================================ + +// MemberOrderApplyVoucher 应用/更换/清除订单优惠(O4 / N1-4 / D3)。 +func MemberOrderApplyVoucher( + ctx context.Context, outTradeNo, promoCode string, userCouponId int64, +) (*v1.MemberOrderApplyVoucherRes, error) { + userId := CtxUserId(ctx) + // 码 / 券互斥:同时传 → 4010(后端校验,不靠前端隐藏) + if err := voucherConflictErr(promoCode, userCouponId); err != nil { + return nil, err + } + var voucher *Voucher + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, err := tx.Model(consts.TableMemberOrders). + Where("out_trade_no", outTradeNo).Where("user_id", userId).LockUpdate().One() + if err != nil { + return err + } + if row.IsEmpty() { + return errOrderInvalid("订单不存在或不属于当前用户") + } + o := orderSnapshotFromRecord(row) + if o.Status != consts.OrderStatusPending { + return errOrderInvalid("订单状态不允许修改优惠") + } + v, err := applyVoucherToOrder(ctx, tx, userId, o, promoCode, userCouponId) + if err != nil { + return err + } + voucher = v + return nil + }) + if err != nil { + return nil, err + } + return &v1.MemberOrderApplyVoucherRes{ + Valid: true, + OriginPriceCents: voucher.OriginPriceCents, + PaidPriceCents: voucher.PaidPriceCents, + DiscountCents: voucher.DiscountCents, + PromoKind: voucher.PromoKind, + PromoCode: voucher.PromoCode, + UserCouponId: voucher.UserCouponId, + Message: voucherAppliedMessage(voucher.PromoKind), + }, nil +} + +// applyVoucherToOrder 统一凭证应用抽象(约定 S3):释放旧占用 → 占用新凭证(或清除)→ 写价格快照。 +// +// 全程在调用方事务内;「券A换券B」= 释放 A + 占用 B,**同一事务**原子完成。 +// 产出 Voucher{productId,originPriceCents,paidPriceCents,discountCents,promoKind,userCouponId}。 +// +// 幂等:重复应用同一凭证 = 先释放再占用,净效果不变(券 status 1→0→1;码 used_count -1→+1)。 +func applyVoucherToOrder( + ctx context.Context, tx gdb.TX, userId int64, o *orderSnapshot, newPromoCode string, newCouponId int64, +) (*Voucher, error) { + targetKey := orderTargetKey(o) + originProductId, originPrice, err := orderTargetProduct(ctx, o.OrderType, targetKey) + if err != nil { + return nil, err + } + // 1) 释放订单当前占用的凭证 + if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil { + return nil, err + } + // 2) 两空 = 清除(价格回原价、清凭证列) + newPromoCode = strings.TrimSpace(newPromoCode) + if newPromoCode == "" && newCouponId <= 0 { + v := &Voucher{ + ProductId: originProductId, + OriginPriceCents: originPrice, + PaidPriceCents: originPrice, + DiscountCents: 0, + PromoKind: consts.PromoKindNone, + } + if err = writeOrderVoucherTx(ctx, tx, o.OutTradeNo, v); err != nil { + return nil, err + } + return v, nil + } + // 3) 占用新凭证(码 / 券互斥由调用方 voucherConflictErr 保证) + var v *Voucher + if newCouponId > 0 { + v, err = occupyFreebie(ctx, tx, userId, newCouponId, o.OrderType, targetKey, o.OutTradeNo, originPrice) + } else { + v, err = occupyPromo(ctx, tx, userId, o.OrderType, targetKey, newPromoCode, originPrice) + } + if err != nil { + return nil, err + } + if err = writeOrderVoucherTx(ctx, tx, o.OutTradeNo, v); err != nil { + return nil, err + } + return v, nil +} + +// releaseVoucherForOrderTx 释放订单当前占用的凭证(码 → used_count-1;券 → 释放回未使用)。 +func releaseVoucherForOrderTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) error { + switch o.PromoKind { + case consts.PromoKindCode: + if err := releasePromoOnOrder(ctx, tx, o.PromoCodeId); err != nil { + return err + } + case consts.PromoKindCoupon: + if o.UserCouponId > 0 { + if err := releaseCouponOnOrder(ctx, tx, o.UserCouponId, o.OutTradeNo); err != nil { + return err + } + } + } + return nil +} + +// rollbackVoucherTx 释放订单占用的凭证并把订单回退到原价(无凭证态)。 +func rollbackVoucherTx(ctx context.Context, tx gdb.TX, o *orderSnapshot) error { + originProductId, originPrice, err := orderTargetProduct(ctx, o.OrderType, orderTargetKey(o)) + if err != nil { + return err + } + if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil { + return err + } + return writeOrderVoucherTx(ctx, tx, o.OutTradeNo, &Voucher{ + ProductId: originProductId, + OriginPriceCents: originPrice, + PaidPriceCents: originPrice, + DiscountCents: 0, + PromoKind: consts.PromoKindNone, + }) +} + +// writeOrderVoucherTx 把价格 + 凭证快照写入订单行。 +func writeOrderVoucherTx(ctx context.Context, tx gdb.TX, outTradeNo string, v *Voucher) error { + _, err := tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{ + "product_id": v.ProductId, + "origin_price_cents": v.OriginPriceCents, + "paid_price_cents": v.PaidPriceCents, + "discount_cents": v.DiscountCents, + "promo_kind": v.PromoKind, + "promo_code_id": v.PromoCodeId, + "promo_code": v.PromoCode, + "user_coupon_id": v.UserCouponId, + "updated_at": gtime.Now(), + }).Update() + return err +} + +// voucherAppliedMessage 应用优惠后的可读提示。 +func voucherAppliedMessage(kind int) string { + switch kind { + case consts.PromoKindCode: + return "优惠码已应用" + case consts.PromoKindCoupon: + return "优惠券已应用" + default: + return "已清除优惠" + } +} + +// ============================================================================ +// 超时关闭(gcron,D2 同源) +// ============================================================================ + +// ClosePendingOrders 关闭超时未支付的待支付订单,并释放其占用中的免单券(gcron 兜底,N1)。 +// +// 幂等:并发 / 重复执行对同一单只会生效一次(事务内按 status 判定)。 +func ClosePendingOrders(ctx context.Context) { + ttl := orderPendingTtlSeconds(ctx) + if ttl <= 0 { + return + } + deadline := gtime.Now().Add(-time.Duration(ttl) * time.Second) + records, err := g.Model(consts.TableMemberOrders). + Where("status", consts.OrderStatusPending). + WhereLT("created_at", deadline). + OrderAsc("id").Limit(200).All() + if err != nil { + g.Log().Warningf(ctx, "[order] 扫描超时待支付订单失败: %v", err) + return + } + closed := 0 + for _, r := range records { + outTradeNo := r["out_trade_no"].String() + if err = closeOrder(ctx, outTradeNo); err != nil { + g.Log().Warningf(ctx, "[order] 关闭超时订单失败 outTradeNo=%s: %v", outTradeNo, err) + continue + } + closed++ + } + if closed > 0 { + g.Log().Infof(ctx, "[order] 已关闭超时未支付订单 %d 笔(ttl=%ds)", closed, ttl) + } +} + +// closeOrder 关闭单笔待支付订单:释放占用凭证 → status=3(已关闭)。幂等。 +func closeOrder(ctx context.Context, outTradeNo string) error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, err := tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).LockUpdate().One() + if err != nil { + return err + } + if row.IsEmpty() { + return nil // 单不存在:幂等返回 + } + o := orderSnapshotFromRecord(row) + if o.Status != consts.OrderStatusPending { + return nil // 已发货 / 已关闭 / 已退款:幂等返回 + } + if err = releaseVoucherForOrderTx(ctx, tx, o); err != nil { + return err + } + _, err = tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).Data(g.Map{ + "status": consts.OrderStatusClosed, + "updated_at": gtime.Now(), + }).Update() + return err + }) +} diff --git a/internal/logic/order_pay_owner_test.go b/internal/logic/order_pay_owner_test.go new file mode 100644 index 0000000..7911f9b --- /dev/null +++ b/internal/logic/order_pay_owner_test.go @@ -0,0 +1,76 @@ +package logic + +import ( + "context" + "net/http" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gcfg" + + "tool-api/internal/consts" +) + +// ============================================================================ +// 订单越权回归(真库 IT,IT_DB 门控) +// +// 两步式(N1)下,订单归属校验必须发生在「详情」与「支付」两条链路: +// 访问**他人**订单的 out_trade_no → 一律 4015(订单不存在 / 不属于当前用户)。 +// 运行:$env:IT_DB=1; go test ./internal/logic/ -run TestOrderOwnership4015 -count=1 -v +// ============================================================================ + +// ctxWithUser 构造带登录用户 id 的最小请求上下文(CtxUserId 依赖 g.RequestFromCtx)。 +func ctxWithUser(userId int64) context.Context { + req := &ghttp.Request{Request: &http.Request{}} + req.SetCtxVar(consts.CtxUserId, userId) + return req.Context() +} + +// ensureXPayConfig 注入最小可用的 wx.pay 配置,使 buildPayParams 越过「未配置」闸门, +// 从而真正走到「支付人 = 登录人」的越权校验(本用例的目的:验证 4015,而非支付配置)。 +func ensureXPayConfig(t *testing.T) { + t.Helper() + if xpayConfig(context.Background()).Configured() { + return + } + adapter, err := gcfg.NewAdapterFile() + if err != nil { + t.Fatalf("创建配置适配器失败: %v", err) + } + adapter.SetContent( + "wx:\n appId: \"probe\"\n appSecret: \"probe\"\n pay:\n offerId: \"probe\"\n appKey: \"probe\"\n env: 0\n", + ) + g.Cfg().SetAdapter(adapter) + if !xpayConfig(context.Background()).Configured() { + t.Fatalf("wx.pay 配置注入后仍未生效") + } +} + +// TestOrderOwnership4015 越权访问他人订单:详情 / 支付均须 4015。 +func TestOrderOwnership4015(t *testing.T) { + ctx := requireOrderIT(t) + p := setupOrderProbe(ctx, t) + defer p.cleanup(ctx) + + res := createMemberOrder(ctx, t, p) // 属于 p.userId 的待支付单 + + other := ctxWithUser(p.userId + 1) + if _, err := MemberOrderDetail(other, res.OutTradeNo); err == nil { + t.Fatalf("/member/order/detail 他人订单应 4015,实得 nil") + } else if codeOf(err) != consts.CodeOrderInvalid { + t.Fatalf("/member/order/detail 他人订单应 4015,实得 code=%d err=%v", codeOf(err), err) + } + + ensureXPayConfig(t) + if _, err := MemberOrderPay(other, res.OutTradeNo, "probe-code"); err == nil { + t.Fatalf("/member/order/pay 他人订单应 4015,实得 nil") + } else if codeOf(err) != consts.CodeOrderInvalid { + t.Fatalf("/member/order/pay 他人订单应 4015,实得 code=%d err=%v", codeOf(err), err) + } + + // 反证:本人取详情应放行 + if _, err := MemberOrderDetail(ctxWithUser(p.userId), res.OutTradeNo); err != nil { + t.Fatalf("本人订单详情应放行,实得 err=%v", err) + } +} diff --git a/internal/logic/order_pay_test.go b/internal/logic/order_pay_test.go new file mode 100644 index 0000000..c70f0c9 --- /dev/null +++ b/internal/logic/order_pay_test.go @@ -0,0 +1,396 @@ +package logic + +import ( + "context" + "strconv" + "sync" + "testing" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" +) + +// ============================================================================ +// 订单两步式单测(T01.6) +// +// 分层: +// - 纯函数(不碰 DB)—— 任何环境 `go test ./...` 都能跑(TestOrderTargetKey / TestVoucherAppliedMessage)。 +// - 真实库集成测试 —— 由 IT_DB 门控(与 softdelete_it_test.go 同一约定): +// $env:IT_DB=1; go test ./internal/logic/ -run TestOrder -count=1 -v +// 覆盖:并发同券恰好 1 单成功、更换优惠幂等、reused 语义、关闭幂等 + 释放占用券、 +// 发货只认本地订单行(改 attach.ProductId 不影响发货,S4)。 +// +// 复用种子数据(plan vip-month / pack quota_50 / tool img-compress)以避免依赖各表 NOT NULL 列细节; +// 探测数据一律带 __probe 前缀并按固定 userId 清理。 +// ============================================================================ + +// ===== 纯函数 ===== + +func TestOrderTargetKey(t *testing.T) { + cases := []struct { + name string + o *orderSnapshot + want string + }{ + {"会员取 plan_key", &orderSnapshot{OrderType: consts.OrderTypeMember, PlanKey: "vip-month", PackKey: "quota_50"}, "vip-month"}, + {"次数包取 pack_key", &orderSnapshot{OrderType: consts.OrderTypeQuota, PlanKey: "", PackKey: "quota_50"}, "quota_50"}, + } + for _, c := range cases { + if got := orderTargetKey(c.o); got != c.want { + t.Errorf("%s: orderTargetKey=%q want %q", c.name, got, c.want) + } + } +} + +func TestVoucherAppliedMessage(t *testing.T) { + cases := map[int]string{ + consts.PromoKindNone: "已清除优惠", + consts.PromoKindCode: "优惠码已应用", + consts.PromoKindCoupon: "优惠券已应用", + } + for kind, want := range cases { + if got := voucherAppliedMessage(kind); got != want { + t.Errorf("voucherAppliedMessage(%d)=%q want %q", kind, got, want) + } + } +} + +// ===== 真实库集成测试 ===== + +// requireOrderIT 打开 IT 门控并跑一次幂等迁移,确保 member_orders 价格列 / user_coupons 锁列齐备。 +func requireOrderIT(t *testing.T) context.Context { + t.Helper() + requireIT(t) + ctx := context.Background() + Migrate(ctx) + return ctx +} + +// probeUserId 固定探测用户 id(极大值,避开真实数据;便于跨运行清理)。 +const probeUserId = int64(900000000000000001) + +// orderProbe 集成测试的探测数据句柄。 +type orderProbe struct { + userId int64 + planKey string + packKey string + toolKey string + code string +} + +func (p *orderProbe) cleanup(ctx context.Context) { + _, _ = g.DB().Exec(ctx, "DELETE FROM member_orders WHERE user_id=?", p.userId) + _, _ = g.DB().Exec(ctx, "DELETE FROM user_coupons WHERE user_id=?", p.userId) + _, _ = g.DB().Exec(ctx, "DELETE FROM user_tool_quota WHERE user_id=?", p.userId) + _, _ = g.DB().Exec(ctx, "DELETE FROM promo_codes WHERE code=?", p.code) + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", p.userId) +} + +// setupOrderProbe 建立探测用户 + 探测优惠码(套餐 / 次数包 / 工具复用种子数据)。 +func setupOrderProbe(ctx context.Context, t *testing.T) *orderProbe { + t.Helper() + p := &orderProbe{ + userId: probeUserId, + planKey: "vip-month", // 种子会员套餐 + packKey: "quota_50", // 种子次数包 + toolKey: "img-compress", // 种子工具 + code: "__PROBE_IPROMO__", + } + // 先清理历史残留(上次运行中断可能留下) + p.cleanup(ctx) + + if _, err := g.Model(consts.TableUsers).Data(g.Map{ + "id": probeUserId, "openid": "__probe_iopenid__", "nickname": "probe-order", + "level_key": consts.DefaultLevelKey, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).Insert(); err != nil { + t.Fatalf("insert probe user: %v", err) + } + if _, err := g.Model(consts.TablePromoCodes).Data(g.Map{ + "code": p.code, "product_id": "admin_1", "price_cents": 1, + "scope": consts.PromoScopeAll, "scope_keys": "[]", "max_uses": 0, + "used_count": 0, "per_user_limit": 0, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).Insert(); err != nil { + t.Fatalf("insert probe promo: %v", err) + } + return p +} + +// applyInTx 在事务内对某订单应用凭证(复用生产函数 applyVoucherToOrder)。 +func applyInTx(ctx context.Context, t *testing.T, userId int64, outTradeNo, code string, couponId int64) (*Voucher, error) { + t.Helper() + var voucher *Voucher + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, err := tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).LockUpdate().One() + if err != nil { + return err + } + if row.IsEmpty() { + return errOrderInvalid("probe order missing: " + outTradeNo) + } + o := orderSnapshotFromRecord(row) + voucher, err = applyVoucherToOrder(ctx, tx, userId, o, code, couponId) + return err + }) + return voucher, err +} + +// createMemberOrder 建一笔探测会员订单。 +func createMemberOrder(ctx context.Context, t *testing.T, p *orderProbe) *createOrderResult { + t.Helper() + res, err := createOrder(ctx, p.userId, consts.OrderTypeMember, "vip_month_990", 990, + g.Map{"plan_key": p.planKey}, + g.Map{ + "order_type": consts.OrderTypeMember, + "plan_key": p.planKey, + "level_key": "v2", + "duration_days": 30, + }) + if err != nil { + t.Fatalf("createOrder(member): %v", err) + } + return res +} + +// TestOrderCreateReused 同商品连续建单 → 待支付单恒为 1 条(reused=true)。 +func TestOrderCreateReused(t *testing.T) { + ctx := requireOrderIT(t) + p := setupOrderProbe(ctx, t) + defer p.cleanup(ctx) + + r1 := createMemberOrder(ctx, t, p) + if r1.Reused { + t.Fatalf("首次建单不应 reused") + } + r2 := createMemberOrder(ctx, t, p) + if !r2.Reused { + t.Fatalf("第二次建单应 reused") + } + if r2.OutTradeNo != r1.OutTradeNo { + t.Fatalf("reused 应返回同一单号:%s vs %s", r1.OutTradeNo, r2.OutTradeNo) + } + n, err := g.Model(consts.TableMemberOrders). + Where("user_id", p.userId).Where("status", consts.OrderStatusPending).Count() + if err != nil { + t.Fatalf("count pending: %v", err) + } + if n != 1 { + t.Fatalf("待支付单应恒为 1 条,实得 %d", n) + } +} + +// TestOrderApplyPromoCodeThenClear 应用优惠码(占用 + 价格快照)→ 幂等重放 → 清除回原价 + 计数回退。 +func TestOrderApplyPromoCodeThenClear(t *testing.T) { + ctx := requireOrderIT(t) + p := setupOrderProbe(ctx, t) + defer p.cleanup(ctx) + res := createMemberOrder(ctx, t, p) + + // 1) 应用优惠码 + v, err := applyInTx(ctx, t, p.userId, res.OutTradeNo, p.code, 0) + if err != nil { + t.Fatalf("apply promo: %v", err) + } + if v.PromoKind != consts.PromoKindCode || v.PaidPriceCents != 1 || v.OriginPriceCents != 990 { + t.Fatalf("应用优惠码价格快照异常: %+v", v) + } + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One() + if row["paid_price_cents"].Int64() != 1 || row["promo_kind"].Int() != consts.PromoKindCode { + t.Fatalf("订单行未写价格/凭证快照: paid=%d kind=%d", + row["paid_price_cents"].Int64(), row["promo_kind"].Int()) + } + if used, _ := g.Model(consts.TablePromoCodes).Where("code", p.code).Value("used_count"); used.Int() != 1 { + t.Fatalf("优惠码 used_count 应为 1,实得 %d", used.Int()) + } + + // 2) 幂等重放:再次应用同一码,used_count 仍为 1(先释放再占用) + if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, p.code, 0); err != nil { + t.Fatalf("重复应用同一码应幂等: %v", err) + } + if used, _ := g.Model(consts.TablePromoCodes).Where("code", p.code).Value("used_count"); used.Int() != 1 { + t.Fatalf("重复应用后 used_count 应仍为 1,实得 %d", used.Int()) + } + + // 3) 清除(两空):价格回原价、凭证列清 0、used_count 回退 + if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, "", 0); err != nil { + t.Fatalf("清除优惠: %v", err) + } + row, _ = g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One() + if row["paid_price_cents"].Int64() != 990 || row["promo_kind"].Int() != consts.PromoKindNone { + t.Fatalf("清除后价格未回原价: paid=%d kind=%d", + row["paid_price_cents"].Int64(), row["promo_kind"].Int()) + } + if used, _ := g.Model(consts.TablePromoCodes).Where("code", p.code).Value("used_count"); used.Int() != 0 { + t.Fatalf("清除后 used_count 应回退为 0,实得 %d", used.Int()) + } +} + +// TestOrderCloseReleasesCoupon 关闭待支付单:status=3 且占用中的免单券被释放,且关闭幂等。 +func TestOrderCloseReleasesCoupon(t *testing.T) { + ctx := requireOrderIT(t) + p := setupOrderProbe(ctx, t) + defer p.cleanup(ctx) + res := createMemberOrder(ctx, t, p) + + // 占用一张免单券 + couponId, err := g.Model(consts.TableUserCoupons).Data(g.Map{ + "coupon_id": 0, "user_id": p.userId, "coupon_title": "探测免单券", + "coupon_type": consts.CouponTypeFreebie, "kind": consts.CouponKindFreebie, + "status": consts.UserCouponStatusUnused, "source": consts.UserCouponSourceAdmin, + "expire_at": gtime.Now().AddDate(1, 0, 0), + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert probe coupon: %v", err) + } + if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, "", couponId); err != nil { + t.Fatalf("apply coupon: %v", err) + } + if st, _ := g.Model(consts.TableUserCoupons).Where("id", couponId).Value("status"); st.Int() != consts.UserCouponStatusUsed { + t.Fatalf("券应被占用,实得 status=%d", st.Int()) + } + + // 关闭(幂等两次) + if err = closeOrder(ctx, res.OutTradeNo); err != nil { + t.Fatalf("closeOrder: %v", err) + } + if err = closeOrder(ctx, res.OutTradeNo); err != nil { + t.Fatalf("重复关闭应幂等: %v", err) + } + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One() + if row["status"].Int() != consts.OrderStatusClosed { + t.Fatalf("订单应已关闭,实得 status=%d", row["status"].Int()) + } + cRow, _ := g.Model(consts.TableUserCoupons).Where("id", couponId).One() + if cRow["status"].Int() != consts.UserCouponStatusUnused { + t.Fatalf("关单后占用券应被释放,实得 status=%d", cRow["status"].Int()) + } +} + +// TestOrderDeliverIgnoresAttach 发货只认本地订单行(S4):篡改 attach.ProductId 不影响发货。 +func TestOrderDeliverIgnoresAttach(t *testing.T) { + ctx := requireOrderIT(t) + p := setupOrderProbe(ctx, t) + defer p.cleanup(ctx) + + res, err := createOrder(ctx, p.userId, consts.OrderTypeQuota, "quota_50", 490, + g.Map{"pack_key": p.packKey, "tool_key": p.toolKey}, + g.Map{ + "order_type": consts.OrderTypeQuota, + "pack_key": p.packKey, + "tool_key": p.toolKey, + "times": 10, + "duration_days": 365, + }) + if err != nil { + t.Fatalf("createOrder(quota): %v", err) + } + // 篡改 attach:塞入一个假 productId(若发货错误地依赖 attach,则要么发错道具、要么失败) + if _, err = g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).Data(g.Map{ + "attach": `{"userId":` + strconv.FormatInt(p.userId, 10) + `,"productId":"HACKED_PRODUCT"}`, + }).Update(); err != nil { + t.Fatalf("tamper attach: %v", err) + } + + if err = deliverOrder(ctx, res.OutTradeNo, "wx_probe_1"); err != nil { + t.Fatalf("deliverOrder: %v", err) + } + row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One() + if row["status"].Int() != consts.OrderStatusDelivered { + t.Fatalf("应发货成功,实得 status=%d", row["status"].Int()) + } + // 发货依据本地订单行的 tool_key / times → 付费额度 +10(与 attach 无关) + q, _ := g.Model(consts.TableUserToolQuota). + Where("user_id", p.userId).Where("tool_key", p.toolKey).One() + if q.IsEmpty() || q["times_left"].Int() != 10 { + t.Fatalf("发货未按本地订单行发放额度:%+v", q) + } +} + +// TestOrderCouponConcurrentOneWins 并发两张订单抢同一张免单券 → 恰好 1 单成功,另一单 4011。 +func TestOrderCouponConcurrentOneWins(t *testing.T) { + ctx := requireOrderIT(t) + p := setupOrderProbe(ctx, t) + defer p.cleanup(ctx) + + // 两张不同商品的订单(不同复用键 → 保证是两笔单) + orderA := createMemberOrder(ctx, t, p) + orderB, err := createOrder(ctx, p.userId, consts.OrderTypeQuota, "quota_50", 490, + g.Map{"pack_key": p.packKey, "tool_key": p.toolKey}, + g.Map{ + "order_type": consts.OrderTypeQuota, + "pack_key": p.packKey, + "tool_key": p.toolKey, + "times": 10, + "duration_days": 365, + }) + if err != nil { + t.Fatalf("createOrder(quota): %v", err) + } + + // 同一张免单券 + couponId, err := g.Model(consts.TableUserCoupons).Data(g.Map{ + "coupon_id": 0, "user_id": p.userId, "coupon_title": "并发探测券", + "coupon_type": consts.CouponTypeFreebie, "kind": consts.CouponKindFreebie, + "status": consts.UserCouponStatusUnused, "source": consts.UserCouponSourceAdmin, + "expire_at": gtime.Now().AddDate(1, 0, 0), + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert probe coupon: %v", err) + } + + orders := []string{orderA.OutTradeNo, orderB.OutTradeNo} + errs := make([]error, 2) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + errs[i] = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, err := tx.Model(consts.TableMemberOrders). + Where("out_trade_no", orders[i]).LockUpdate().One() + if err != nil { + return err + } + o := orderSnapshotFromRecord(row) + _, err = applyVoucherToOrder(ctx, tx, p.userId, o, "", couponId) + return err + }) + }(i) + } + close(start) + wg.Wait() + + success := 0 + for i, e := range errs { + if e == nil { + success++ + continue + } + if codeOf(e) != consts.CodeCouponUnavailable { + t.Errorf("第 %d 单失败方应为 4011,实得 code=%d err=%v", i, codeOf(e), e) + } + } + if success != 1 { + t.Fatalf("并发同券应恰好 1 单成功,实得 %d 单", success) + } + // 券只对应 1 个订单号,且处于占用态 + cRow, _ := g.Model(consts.TableUserCoupons).Where("id", couponId).One() + if cRow["status"].Int() != consts.UserCouponStatusUsed || cRow["used_order_no"].String() == "" { + t.Fatalf("券应被恰好一单占用,实得 status=%d order=%q", + cRow["status"].Int(), cRow["used_order_no"].String()) + } + // 恰有一笔订单写入了券 + n, _ := g.Model(consts.TableMemberOrders). + Where("user_id", p.userId).Where("user_coupon_id", couponId).Count() + if n != 1 { + t.Fatalf("应恰有 1 笔订单占用该券,实得 %d", n) + } +} diff --git a/internal/logic/order_pay_tz_test.go b/internal/logic/order_pay_tz_test.go new file mode 100644 index 0000000..01c9334 --- /dev/null +++ b/internal/logic/order_pay_tz_test.go @@ -0,0 +1,78 @@ +package logic + +import ( + "context" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" +) + +// ============================================================================ +// T06 · 优惠域时区错位回归(真库 IT,IT_DB 门控) +// +// 背景:读侧恒定走 gtime.StrToTime(恒 Local,与 DSN 无关),写入走驱动 loc(UTC), +// 于是「Go 侧读回值」比真实早 8h —— 任何 `readback.Before/After(now)` 的过期判定都会错。 +// 唯一正确治法:把 expire_at / valid_to 的过期判定挪到 **SQL 侧**(绑定 gtime 参数, +// 与写入同源),绝不在 Go 侧比较读回时间。 +// +// 本用例即 T06 验收: +// 断言① expire_at = now+30min 的免单券应**放行**(可占用); +// 断言② expire_at = now-1min 的免单券应**拒绝**(4011)。 +// +// 修复前:断言①因读回早 8h 被判「已过期」而拒绝 → 本用例失败(即 T06 的失败证据)。 +// +// 运行:$env:IT_DB=1; go test ./internal/logic/ -run TestOrderCouponExpireSqlSide -count=1 -v +// ============================================================================ + +// insertProbeCoupon 插入一张探测免单券(指定 expire_at),返回券 id。 +func insertProbeCoupon(ctx context.Context, userId int64, expireAt *gtime.Time) (int64, error) { + return g.Model(consts.TableUserCoupons).Data(g.Map{ + "coupon_id": 0, + "user_id": userId, + "coupon_title": "TZ 探测免单券", + "coupon_type": consts.CouponTypeFreebie, + "kind": consts.CouponKindFreebie, + "status": consts.UserCouponStatusUnused, + "source": consts.UserCouponSourceAdmin, + "expire_at": expireAt, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).InsertAndGetId() +} + +// TestOrderCouponExpireSqlSide 券过期判定 SQL 侧归一(T06 验收)。 +func TestOrderCouponExpireSqlSide(t *testing.T) { + ctx := requireOrderIT(t) + p := setupOrderProbe(ctx, t) + defer p.cleanup(ctx) + + res := createMemberOrder(ctx, t, p) + + // 断言①:expire_at = now+30min → 应放行(可占用) + freshId, err := insertProbeCoupon(ctx, p.userId, gtime.Now().Add(30*time.Minute)) + if err != nil { + t.Fatalf("insert fresh coupon: %v", err) + } + if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, "", freshId); err != nil { + t.Fatalf("断言①失败:expire_at=now+30min 应放行,实得 err=%v (code=%d)", err, codeOf(err)) + } + // 清除优惠(释放刚占用的券),不影响下一断言 + if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, "", 0); err != nil { + t.Fatalf("清除优惠失败: %v", err) + } + + // 断言②:expire_at = now-1min → 应拒绝 4011 + staleId, err := insertProbeCoupon(ctx, p.userId, gtime.Now().Add(-1*time.Minute)) + if err != nil { + t.Fatalf("insert stale coupon: %v", err) + } + if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, "", staleId); err == nil { + t.Fatalf("断言②失败:expire_at=now-1min 应被拒绝,实得放行") + } else if codeOf(err) != consts.CodeCouponUnavailable { + t.Fatalf("断言②失败:过期券应返 4011,实得 code=%d err=%v", codeOf(err), err) + } +} diff --git a/internal/logic/promo.go b/internal/logic/promo.go new file mode 100644 index 0000000..b682889 --- /dev/null +++ b/internal/logic/promo.go @@ -0,0 +1,623 @@ +package logic + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 优惠凭证域 · 优惠码(迭代-2026-09-20,T04.3) +// +// 覆盖: +// - 管理端 CRUD:list / save / toggle / delete / usages(A1–A5) +// - 用户端只读校验:POST /promo/validate(P1,分类报错 + 错误限流 4013) +// - 下单事务内的原子占用原语 occupyPromo(已在 order_pay.go 的 applyVoucherToOrder 内接线) +// ============================================================================ + +// Voucher 统一优惠凭证抽象(约定 S3)。 +// +// 由优惠码 / 免单券 / 无凭证三条路径统一产出,供下单链路的订单价格快照与支付签名复用。 +type Voucher struct { + ProductId string // 用券后实际走支付的道具 + OriginPriceCents int64 // 商品原价(分) + PaidPriceCents int64 // 实付(分) + DiscountCents int64 // 优惠金额(分)= origin - paid + PromoKind int // 0无 1优惠码 2免单券 + PromoCodeId int64 + PromoCode string + UserCouponId int64 +} + +// ===== 错误码构造(架构 §4.5 统一裁定)===== + +func gcodeOf(code int) gcode.Code { return gcode.New(code, "", nil) } + +// errPromoInvalid 4010:优惠码无效/过期/达上限/超单人限用/不适用;或凭证互斥 +func errPromoInvalid(msg string) error { + return gerror.NewCode(gcodeOf(consts.CodePromoInvalid), msg) +} + +// errCouponUnavailable 4011:券不可用/已用/过期/作废;免单券并发占用失败 +func errCouponUnavailable(msg string) error { + return gerror.NewCode(gcodeOf(consts.CodeCouponUnavailable), msg) +} + +// errLevelInvalid 4012:等级不存在或已停用 +func errLevelInvalid(msg string) error { + return gerror.NewCode(gcodeOf(consts.CodeLevelInvalid), msg) +} + +// errPromoRateLimited 4013:同一用户 1 分钟内优惠码错误次数超限 +func errPromoRateLimited(msg string) error { + return gerror.NewCode(gcodeOf(consts.CodePromoRateLimited), msg) +} + +// errProductPriceMismatch 4014:服务端自检(道具价格与预期不符),不下发用户 +func errProductPriceMismatch(msg string) error { + return gerror.NewCode(gcodeOf(consts.CodeProductPriceMismatch), msg) +} + +// ===== settings 读取(约定 S9:配置一律走 settings 表,不写 config.yaml)===== + +// freebieProductId 免单道具 ID(后台可配,默认 admin_1) +func freebieProductId(ctx context.Context) string { + if v := strings.TrimSpace(SettingValue(ctx, consts.SettingFreebieProductId)); v != "" { + return v + } + return consts.DefaultFreebieProductId +} + +// freebiePriceCents 免单道具价格(分,默认 1) +func freebiePriceCents(ctx context.Context) int64 { + raw := strings.TrimSpace(SettingValue(ctx, consts.SettingFreebiePriceCents)) + if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 { + return n + } + return consts.DefaultFreebiePriceCents +} + +// promoFailLimitPerMin 同一用户 1 分钟内优惠码错误次数上限(默认 6) +func promoFailLimitPerMin(ctx context.Context) int { + raw := strings.TrimSpace(SettingValue(ctx, consts.SettingPromoFailLimitPerMin)) + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + return n + } + return consts.DefaultPromoFailLimitPerMin +} + +// couponLockTtlSeconds 免单券下单占用自动释放时长(秒,默认 900) +func couponLockTtlSeconds(ctx context.Context) int { + raw := strings.TrimSpace(SettingValue(ctx, consts.SettingCouponLockTtlSeconds)) + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + return n + } + return consts.DefaultCouponLockTtlSeconds +} + +// ===== 纯函数(可单测)===== + +// normalizePromoCode 归一化优惠码:去空白 + 转大写。 +func normalizePromoCode(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +// voucherConflictErr 凭证互斥校验(纯函数,约定 S3): +// promo_code 与 user_coupon_id **同时传** → 4010(后端校验,不靠前端隐藏)。 +func voucherConflictErr(promoCode string, userCouponId int64) error { + if strings.TrimSpace(promoCode) != "" && userCouponId > 0 { + return errPromoInvalid("优惠码与优惠券不能同时使用") + } + return nil +} + +// parseScopeKeys 解析适用范围 JSON 数组;解析失败返回空切片(等价于「不限制具体 key」)。 +func parseScopeKeys(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return []string{} + } + keys := []string{} + if err := json.Unmarshal([]byte(raw), &keys); err != nil { + return []string{} + } + return keys +} + +// scopeMatches 判断 scope + scopeKeys 是否覆盖 orderType/targetKey。 +// - scope 0(全部)→ 恒真 +// - scope 1(指定会员套餐)→ orderType 必须为 1 且 targetKey 命中 scopeKeys +// - scope 2(指定次数包)→ orderType 必须为 2 且 targetKey 命中 scopeKeys +// +// scopeKeys 为空视为「该 scope 下无任何适用对象」→ false(fail-closed)。 +func scopeMatches(scope int, scopeKeys []string, orderType int, targetKey string) bool { + switch scope { + case consts.PromoScopeAll: + return true + case consts.PromoScopePlan: + if orderType != consts.OrderTypeMember { + return false + } + case consts.PromoScopePack: + if orderType != consts.OrderTypeQuota { + return false + } + default: + return false + } + for _, k := range scopeKeys { + if k == targetKey { + return true + } + } + return false +} + +// promoPriceError 优惠码可用性判定(纯函数,不碰 DB)。 +// usedByUser 由调用方查库传入(「该用户已用该码的下单数」)。 +// 返回 nil 表示可用;否则返回 4010 分类错误。 +func promoPriceError(p *entity.PromoCodes, now *gtime.Time, orderType int, targetKey string, usedByUser int) error { + if p == nil { + return errPromoInvalid("优惠码无效") + } + if p.Status != 1 { + return errPromoInvalid("优惠码已停用") + } + if p.ValidFrom != nil && !p.ValidFrom.IsZero() && now.Before(p.ValidFrom) { + return errPromoInvalid("优惠码尚未生效") + } + if p.ValidTo != nil && !p.ValidTo.IsZero() && now.After(p.ValidTo) { + return errPromoInvalid("优惠码已过期") + } + if p.MaxUses > 0 && p.UsedCount >= p.MaxUses { + return errPromoInvalid("优惠码已达使用上限") + } + if p.PerUserLimit > 0 && usedByUser >= p.PerUserLimit { + return errPromoInvalid("你已达到该优惠码的限用次数") + } + if !scopeMatches(p.Scope, parseScopeKeys(p.ScopeKeys), orderType, targetKey) { + return errPromoInvalid("优惠码不适用于当前商品") + } + return nil +} + +// ===== 优惠码错误限流(无 Redis,进程内滑窗)===== + +type promoFailWindow struct { + mu sync.Mutex + m map[int64][]time.Time +} + +var promoFails = &promoFailWindow{m: map[int64][]time.Time{}} + +// promoFailCount 返回该用户最近 1 分钟内的错误次数。 +func promoFailCount(userId int64) int { + if userId <= 0 { + return 0 + } + cutoff := time.Now().Add(-time.Minute) + promoFails.mu.Lock() + defer promoFails.mu.Unlock() + kept := make([]time.Time, 0, len(promoFails.m[userId])) + for _, t := range promoFails.m[userId] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + promoFails.m[userId] = kept + return len(kept) +} + +// recordPromoFailure 记一次优惠码错误。 +func recordPromoFailure(userId int64) { + if userId <= 0 { + return + } + promoFails.mu.Lock() + defer promoFails.mu.Unlock() + promoFails.m[userId] = append(promoFails.m[userId], time.Now()) +} + +// clearPromoFailures 校验成功后清空该用户的错误窗口。 +func clearPromoFailures(userId int64) { + if userId <= 0 { + return + } + promoFails.mu.Lock() + defer promoFails.mu.Unlock() + delete(promoFails.m, userId) +} + +// ===== DB 辅助 ===== + +// orderTargetProduct 返回目标商品的道具 ID 与价格(分)。 +// orderType 1=会员套餐(plan_key)/ 2=次数包(pack_key)。 +func orderTargetProduct(ctx context.Context, orderType int, targetKey string) (productId string, priceCents int64, err error) { + switch orderType { + case consts.OrderTypeMember: + record, qErr := g.Model(consts.TableMemberPlans). + Where("plan_key", targetKey).Where("is_enabled", 1).One() + if qErr != nil { + return "", 0, qErr + } + if record.IsEmpty() { + return "", 0, gerror.New("会员套餐不存在或已下架") + } + return record["product_id"].String(), record["price_cents"].Int64(), nil + case consts.OrderTypeQuota: + record, qErr := g.Model(consts.TableQuotaPacks). + Where("pack_key", targetKey).Where("is_enabled", 1).One() + if qErr != nil { + return "", 0, qErr + } + if record.IsEmpty() { + return "", 0, gerror.New("次数包不存在或已下架") + } + return record["product_id"].String(), record["price_cents"].Int64(), nil + default: + return "", 0, gerror.New("订单类型非法") + } +} + +func promoByCode(ctx context.Context, code string) (*entity.PromoCodes, error) { + record, err := g.Model(consts.TablePromoCodes).Where("code", code).One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, nil + } + promo := &entity.PromoCodes{} + if err = record.Struct(promo); err != nil { + return nil, err + } + return promo, nil +} + +// promoUsedCountByUser 该用户已用该优惠码下单的次数(仅统计未关闭的订单)。 +func promoUsedCountByUser(ctx context.Context, promoId, userId int64) (int, error) { + if promoId <= 0 || userId <= 0 { + return 0, nil + } + return g.Model(consts.TableMemberOrders). + Where("promo_code_id", promoId). + Where("user_id", userId). + WhereNot("status", consts.OrderStatusClosed). + Count() +} + +// ===== 用户端:/promo/validate(P1,只读,绝不占用)===== + +// PromoValidate 校验优惠码:服务端为准,前端只展示。 +// +// 分类报错(4010):优惠码无效 / 已停用 / 未生效 / 已过期 / 达总上限 / 超单人限用 / 不适用当前商品; +// 错误次数超过 promo.fail.limitPerMin(默认 6/分钟)→ 4013。 +func PromoValidate(ctx context.Context, code string, orderType int, targetKey string) (*userv1.PromoValidateRes, error) { + userId := CtxUserId(ctx) + res := &userv1.PromoValidateRes{} + + // 先判限流:已达上限直接拒绝,避免继续爆破 + if promoFailCount(userId) >= promoFailLimitPerMin(ctx) { + return nil, errPromoRateLimited("优惠码尝试过于频繁,请稍后再试") + } + + _, originPrice, oErr := orderTargetProduct(ctx, orderType, targetKey) + if oErr != nil { + res.Valid = false + res.Message = oErr.Error() + return res, nil + } + res.OriginPriceCents = originPrice + + code = normalizePromoCode(code) + if code == "" { + recordPromoFailure(userId) + return nil, errPromoInvalid("请输入优惠码") + } + + promo, err := promoByCode(ctx, code) + if err != nil { + return nil, err + } + if promo == nil { + recordPromoFailure(userId) + return nil, errPromoInvalid("优惠码无效") + } + usedByUser, err := promoUsedCountByUser(ctx, promo.Id, userId) + if err != nil { + return nil, err + } + if err = promoPriceError(promo, gtime.Now(), orderType, targetKey, usedByUser); err != nil { + recordPromoFailure(userId) + return nil, err + } + + clearPromoFailures(userId) + discount := originPrice - promo.PriceCents + if discount < 0 { + discount = 0 + } + res.Valid = true + res.ProductId = promo.ProductId + res.PaidPriceCents = promo.PriceCents + res.DiscountCents = discount + res.Message = "优惠码可用" + return res, nil +} + +// ===== 下单事务内的原子占用原语(已在 order_pay.go 的 applyVoucherToOrder 内接线)===== + +// occupyPromo 在事务内锁定并占用一个优惠码:行锁 → 校验 → used_count+1。 +// +// 与订单插入同一事务(约定 S5):任一步失败整体回滚。并发同码下单时, +// 后到者会阻塞在 LockUpdate 上,拿到已自增的 used_count 后校验失败(达上限)→ 4010。 +func occupyPromo( + ctx context.Context, tx gdb.TX, userId int64, orderType int, targetKey, code string, originPriceCents int64, +) (*Voucher, error) { + code = normalizePromoCode(code) + row, err := tx.Model(consts.TablePromoCodes).Where("code", code).LockUpdate().One() + if err != nil { + return nil, err + } + if row.IsEmpty() { + return nil, errPromoInvalid("优惠码无效") + } + promo := &entity.PromoCodes{} + if err = row.Struct(promo); err != nil { + return nil, err + } + usedByUser, err := promoUsedCountByUserTx(ctx, tx, promo.Id, userId) + if err != nil { + return nil, err + } + if err = promoPriceError(promo, gtime.Now(), orderType, targetKey, usedByUser); err != nil { + return nil, err + } + if _, err = tx.Model(consts.TablePromoCodes).Where("id", promo.Id). + Increment("used_count", 1); err != nil { + return nil, err + } + discount := originPriceCents - promo.PriceCents + if discount < 0 { + discount = 0 + } + return &Voucher{ + ProductId: promo.ProductId, + OriginPriceCents: originPriceCents, + PaidPriceCents: promo.PriceCents, + DiscountCents: discount, + PromoKind: consts.PromoKindCode, + PromoCodeId: promo.Id, + PromoCode: promo.Code, + }, nil +} + +func promoUsedCountByUserTx(ctx context.Context, tx gdb.TX, promoId, userId int64) (int, error) { + if promoId <= 0 || userId <= 0 { + return 0, nil + } + return tx.Model(consts.TableMemberOrders). + Where("promo_code_id", promoId). + Where("user_id", userId). + WhereNot("status", consts.OrderStatusClosed). + Count() +} + +// releasePromoOnOrder 释放订单占用的优惠码(换码 / 改回无券 / 关单时调用):used_count-1。 +// +// 守卫:仅当 used_count > 0 才自减,避免脏数据把计数减成负数。与 occupyPromo 同处 order_pay.go +// 的 applyVoucherToOrder 事务内调用 → 释放旧 + 占用新原子完成(约定 S5)。 +func releasePromoOnOrder(ctx context.Context, tx gdb.TX, promoCodeId int64) error { + if promoCodeId <= 0 { + return nil + } + _, err := tx.Model(consts.TablePromoCodes). + Where("id", promoCodeId).WhereGT("used_count", 0). + Decrement("used_count", 1) + return err +} + +// ===== 管理端:优惠码 CRUD(A1–A5)===== + +// AdminPromoList 优惠码列表 +func AdminPromoList(ctx context.Context, req *adminv1.PromoListReq) (*adminv1.PromoListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TablePromoCodes) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + m = m.Where("code LIKE ?", "%"+strings.ToUpper(kw)+"%") + } + if req.Status != nil { + m = m.Where("status", *req.Status) + } + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).OrderDesc("id").All() + if err != nil { + return nil, err + } + list := make([]adminv1.PromoItem, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.PromoItem{ + Id: r["id"].Int64(), + Code: r["code"].String(), + ProductId: r["product_id"].String(), + PriceCents: r["price_cents"].Int64(), + Scope: r["scope"].Int(), + ScopeKeys: parseScopeKeys(r["scope_keys"].String()), + MaxUses: r["max_uses"].Int(), + UsedCount: r["used_count"].Int(), + PerUserLimit: r["per_user_limit"].Int(), + ValidFrom: timeStr(r["valid_from"].GTime()), + ValidTo: timeStr(r["valid_to"].GTime()), + Status: r["status"].Int(), + Remark: r["remark"].String(), + }) + } + return &adminv1.PromoListRes{List: list, Total: total}, nil +} + +// AdminPromoSave 新增/编辑优惠码。 +// 编辑(id>0)时 code 不可变更(跨端契约:码值可能已被用户输入/被订单快照)。 +func AdminPromoSave(ctx context.Context, req *adminv1.PromoSaveReq) (*adminv1.PromoSaveRes, error) { + code := normalizePromoCode(req.Code) + if code == "" { + return nil, errPromoInvalid("优惠码不能为空") + } + scopeKeysJSON, _ := json.Marshal(req.ScopeKeys) + validFrom := parseOptionalTime(req.ValidFrom) + validTo := parseOptionalTime(req.ValidTo) + if validFrom != nil && validTo != nil && validTo.Before(validFrom) { + return nil, gerror.New("结束时间不能早于开始时间") + } + data := g.Map{ + "product_id": strings.TrimSpace(req.ProductId), + "price_cents": req.PriceCents, + "scope": req.Scope, + "scope_keys": string(scopeKeysJSON), + "max_uses": req.MaxUses, + "per_user_limit": req.PerUserLimit, + "valid_from": validFrom, + "valid_to": validTo, + "status": req.Status, + "remark": req.Remark, + "updated_at": gtime.Now(), + } + if req.Id > 0 { + exist, err := g.Model(consts.TablePromoCodes).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("优惠码不存在") + } + if exist["code"].String() != code { + return nil, gerror.New("优惠码值不可修改") + } + if _, err = g.Model(consts.TablePromoCodes).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + return &adminv1.PromoSaveRes{Id: req.Id}, nil + } + count, err := g.Model(consts.TablePromoCodes).Where("code", code).Count() + if err != nil { + return nil, err + } + if count > 0 { + return nil, gerror.New("优惠码值已存在") + } + data["code"] = code + data["created_by"] = CtxAdminAccount(ctx) + data["created_at"] = gtime.Now() + id, err := g.Model(consts.TablePromoCodes).Data(data).InsertAndGetId() + if err != nil { + return nil, err + } + return &adminv1.PromoSaveRes{Id: id}, nil +} + +// AdminPromoToggle 启停优惠码 +func AdminPromoToggle(ctx context.Context, id int64, status int) (*adminv1.PromoToggleRes, error) { + if _, err := g.Model(consts.TablePromoCodes).Where("id", id). + Data(g.Map{"status": status, "updated_at": gtime.Now()}).Update(); err != nil { + return nil, err + } + return &adminv1.PromoToggleRes{}, nil +} + +// AdminPromoDelete 删除优惠码 +func AdminPromoDelete(ctx context.Context, id int64) (*adminv1.PromoDeleteRes, error) { + if _, err := g.Model(consts.TablePromoCodes).Where("id", id).Delete(); err != nil { + return nil, err + } + return &adminv1.PromoDeleteRes{}, nil +} + +// AdminPromoUsages 优惠码使用明细 +func AdminPromoUsages(ctx context.Context, req *adminv1.PromoUsagesReq) (*adminv1.PromoUsagesRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableMemberOrders).Where("promo_code_id", req.PromoId) + total, err := m.Count() + if err != nil { + return nil, err + } + records, err := m.Page(page, pageSize).OrderDesc("id").All() + if err != nil { + return nil, err + } + list := make([]adminv1.PromoUsageItem, 0, len(records)) + for _, r := range records { + at := r["paid_at"].GTime() + if at == nil || at.IsZero() { + at = r["created_at"].GTime() + } + list = append(list, adminv1.PromoUsageItem{ + At: timeStr(at), + User: userDisplayOf(ctx, r["user_id"].Int64()), + OutTradeNo: r["out_trade_no"].String(), + OriginPriceCents: r["origin_price_cents"].Int64(), + PaidPriceCents: r["paid_price_cents"].Int64(), + }) + } + return &adminv1.PromoUsagesRes{List: list, Total: total}, nil +} + +// ===== 通用小工具 ===== + +// normalizePage 归一化分页参数(page>=1;pageSize 落在 (0, 200])。 +func normalizePage(page, pageSize, def int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = def + } + if pageSize > 200 { + pageSize = 200 + } + return page, pageSize +} + +// timeStr 统一时间输出("Y-m-d H:i:s");空/零值返回空串。 +func timeStr(t *gtime.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("Y-m-d H:i:s") +} + +// parseOptionalTime 解析可选时间(空串 → nil)。 +func parseOptionalTime(s string) *gtime.Time { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + return gtime.New(s) +} + +// userDisplayOf 用户展示名(昵称优先,回落 "用户#id")。 +func userDisplayOf(ctx context.Context, userId int64) string { + if userId <= 0 { + return "" + } + v, err := g.Model(consts.TableUsers).Where("id", userId).Value("nickname") + if err != nil || v == nil || v.String() == "" { + return "用户#" + strconv.FormatInt(userId, 10) + } + return v.String() +} diff --git a/internal/logic/promo_test.go b/internal/logic/promo_test.go new file mode 100644 index 0000000..70d3e01 --- /dev/null +++ b/internal/logic/promo_test.go @@ -0,0 +1,158 @@ +package logic + +import ( + "testing" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 优惠码单测(T04.8) +// +// 覆盖纯函数:码校验分类、适用范围匹配、互斥(同时传码与券 → 4010)、限流计数。 +// 全部不依赖 DB,保证 `go test ./...` 在任何环境可跑。 +// ============================================================================ + +// codeOf 取 gerror 的业务码(无 code 返回 -1)。 +func codeOf(err error) int { + if err == nil { + return 0 + } + if c := gerror.Code(err); c != nil { + return c.Code() + } + return -1 +} + +func TestNormalizePromoCode(t *testing.T) { + cases := map[string]string{ + " abc123 ": "ABC123", + "XYZ": "XYZ", + "": "", + } + for in, want := range cases { + if got := normalizePromoCode(in); got != want { + t.Errorf("normalizePromoCode(%q) = %q, want %q", in, got, want) + } + } +} + +func TestScopeMatches(t *testing.T) { + keys := []string{"vip-month", "quota_100"} + cases := []struct { + name string + scope int + orderType int + targetKey string + want bool + }{ + {"全部恒真", consts.PromoScopeAll, consts.OrderTypeMember, "vip-month", true}, + {"全部恒真-次数包", consts.PromoScopeAll, consts.OrderTypeQuota, "quota_100", true}, + {"指定套餐命中", consts.PromoScopePlan, consts.OrderTypeMember, "vip-month", true}, + {"指定套餐-订单类型不符", consts.PromoScopePlan, consts.OrderTypeQuota, "vip-month", false}, + {"指定套餐-key 不匹配", consts.PromoScopePlan, consts.OrderTypeMember, "vip-year", false}, + {"指定次数包命中", consts.PromoScopePack, consts.OrderTypeQuota, "quota_100", true}, + {"指定次数包-订单类型不符", consts.PromoScopePack, consts.OrderTypeMember, "quota_100", false}, + {"未知 scope fail-closed", 9, consts.OrderTypeMember, "vip-month", false}, + } + for _, c := range cases { + if got := scopeMatches(c.scope, keys, c.orderType, c.targetKey); got != c.want { + t.Errorf("%s: scopeMatches=%v, want %v", c.name, got, c.want) + } + } + // scopeKeys 为空 → 指定 scope 下无任何适用对象(fail-closed) + if scopeMatches(consts.PromoScopePlan, nil, consts.OrderTypeMember, "vip-month") { + t.Error("scopeKeys 为空时 scope=指定套餐 应判为不匹配") + } +} + +func TestPromoPriceError(t *testing.T) { + now := gtime.Now() + base := func() *entity.PromoCodes { + return &entity.PromoCodes{ + Id: 1, Code: "OK", ProductId: "admin_1", PriceCents: 1, + Scope: consts.PromoScopeAll, MaxUses: 0, UsedCount: 0, + PerUserLimit: 1, Status: 1, + } + } + cases := []struct { + name string + mutate func(*entity.PromoCodes) + orderType int + targetKey string + usedByUser int + wantCode int // 0 = 通过 + }{ + {"正常可用", func(p *entity.PromoCodes) {}, consts.OrderTypeMember, "vip-month", 0, 0}, + {"nil", nil, consts.OrderTypeMember, "vip-month", 0, consts.CodePromoInvalid}, + {"已停用", func(p *entity.PromoCodes) { p.Status = 0 }, consts.OrderTypeMember, "vip-month", 0, consts.CodePromoInvalid}, + {"未生效", func(p *entity.PromoCodes) { p.ValidFrom = gtime.New("2999-01-01 00:00:00") }, consts.OrderTypeMember, "vip-month", 0, consts.CodePromoInvalid}, + {"已过期", func(p *entity.PromoCodes) { p.ValidTo = gtime.New("2000-01-01 00:00:00") }, consts.OrderTypeMember, "vip-month", 0, consts.CodePromoInvalid}, + {"达总上限", func(p *entity.PromoCodes) { p.MaxUses = 3; p.UsedCount = 3 }, consts.OrderTypeMember, "vip-month", 0, consts.CodePromoInvalid}, + {"超单人限用", func(p *entity.PromoCodes) { p.PerUserLimit = 1 }, consts.OrderTypeMember, "vip-month", 1, consts.CodePromoInvalid}, + {"不适用-类型", func(p *entity.PromoCodes) { + p.Scope = consts.PromoScopePlan + p.ScopeKeys = `["vip-month"]` + }, consts.OrderTypeQuota, "quota_100", 0, consts.CodePromoInvalid}, + {"适用-指定套餐", func(p *entity.PromoCodes) { + p.Scope = consts.PromoScopePlan + p.ScopeKeys = `["vip-month"]` + }, consts.OrderTypeMember, "vip-month", 0, 0}, + } + for _, c := range cases { + p := base() + if c.mutate == nil { + p = nil + } else { + c.mutate(p) + } + err := promoPriceError(p, now, c.orderType, c.targetKey, c.usedByUser) + if got := codeOf(err); got != c.wantCode { + t.Errorf("%s: code=%d (err=%v), want %d", c.name, got, err, c.wantCode) + } + } +} + +// TestVoucherMutualExclusion 互斥:promo_code 与 user_coupon_id 同时传 → 4010。 +func TestVoucherMutualExclusion(t *testing.T) { + if err := voucherConflictErr("ABC", 0); err != nil { + t.Errorf("仅传优惠码不应报错,got %v", err) + } + if err := voucherConflictErr("", 99); err != nil { + t.Errorf("仅传优惠券不应报错,got %v", err) + } + if err := voucherConflictErr("", 0); err != nil { + t.Errorf("都不传不应报错,got %v", err) + } + err := voucherConflictErr("ABC", 99) + if codeOf(err) != consts.CodePromoInvalid { + t.Errorf("同时传码与券应返回 4010,got code=%d err=%v", codeOf(err), err) + } +} + +// TestPromoFailWindow 限流窗口:错误计数→清空→再计数。 +func TestPromoFailWindow(t *testing.T) { + const uid = int64(987654321) + clearPromoFailures(uid) + if n := promoFailCount(uid); n != 0 { + t.Fatalf("初始应为 0,got %d", n) + } + recordPromoFailure(uid) + recordPromoFailure(uid) + if n := promoFailCount(uid); n != 2 { + t.Fatalf("记两次后应为 2,got %d", n) + } + clearPromoFailures(uid) + if n := promoFailCount(uid); n != 0 { + t.Fatalf("清空后应为 0,got %d", n) + } + // 非正 userId 不计数(避免匿名/边界写入) + recordPromoFailure(0) + if n := promoFailCount(0); n != 0 { + t.Fatalf("userId<=0 不应计数,got %d", n) + } +} diff --git a/internal/logic/quota.go b/internal/logic/quota.go index d6aa686..fd269a2 100644 --- a/internal/logic/quota.go +++ b/internal/logic/quota.go @@ -111,7 +111,7 @@ func EffectiveQuotaPeriodDays(ctx context.Context, user *entity.Users) int { // ===== 工具读取 ===== func toolRow(ctx context.Context, toolKey string) (*entity.Tools, error) { - record, err := g.Model(consts.TableTools).Where("tool_key", toolKey).One() + record, err := g.Model(consts.TableTools).Where("tool_key", toolKey).WhereNull("deleted_at").One() if err != nil { return nil, err } diff --git a/internal/logic/quota_api.go b/internal/logic/quota_api.go index 37b8e66..3f8f338 100644 --- a/internal/logic/quota_api.go +++ b/internal/logic/quota_api.go @@ -90,8 +90,8 @@ func QuotaPacksFor(ctx context.Context, toolKey string) (*v1.QuotaPacksRes, erro return &v1.QuotaPacksRes{List: list}, nil } -// QuotaOrderCreate 创建次数包订单;与会员订单共用下单/发货链路 -func QuotaOrderCreate(ctx context.Context, packKey, toolKey, code string) (*v1.QuotaOrderCreateRes, error) { +// QuotaOrderCreate 创建次数包订单(**只建单**,N1:与会员套餐两步式同款,共用 createOrder)。 +func QuotaOrderCreate(ctx context.Context, packKey, toolKey string) (*v1.QuotaOrderCreateRes, error) { userId := CtxUserId(ctx) if !xpayConfig(ctx).Configured() { return nil, gerror.NewCode(gcodePayFail(), @@ -128,27 +128,28 @@ func QuotaOrderCreate(ctx context.Context, packKey, toolKey, code string) (*v1.Q validDays = 365 } - pay, err := prepareXpayOrder(ctx, userId, code, pack.ProductId, pack.PriceCents, g.Map{ - "order_type": consts.OrderTypeQuota, - "pack_key": pack.PackKey, - "tool_key": tool.ToolKey, - "times": pack.Times, - "duration_days": validDays, // 次数包复用 duration_days 承载「有效期天数」 - }) + res, err := createOrder(ctx, userId, consts.OrderTypeQuota, pack.ProductId, pack.PriceCents, + g.Map{"pack_key": pack.PackKey, "tool_key": tool.ToolKey}, + g.Map{ + "order_type": consts.OrderTypeQuota, + "pack_key": pack.PackKey, + "tool_key": tool.ToolKey, + "times": pack.Times, + "duration_days": validDays, // 次数包复用 duration_days 承载「有效期天数」 + }) if err != nil { return nil, err } return &v1.QuotaOrderCreateRes{ - OutTradeNo: pay.OutTradeNo, - Mode: consts.XPayMode, - SignData: pay.SignData, - PaySig: pay.PaySig, - Signature: pay.Signature, - Env: pay.Env, - PackKey: pack.PackKey, - ToolKey: tool.ToolKey, - Times: pack.Times, - ValidDays: validDays, + OutTradeNo: res.OutTradeNo, + Reused: res.Reused, + OriginPriceCents: res.OriginPriceCents, + PaidPriceCents: res.PaidPriceCents, + PackKey: pack.PackKey, + ToolKey: tool.ToolKey, + Times: pack.Times, + ValidDays: validDays, + Status: res.Status, }, nil } @@ -156,7 +157,7 @@ func QuotaOrderCreate(ctx context.Context, packKey, toolKey, code string) (*v1.Q // quotaListForUser 用户可见(已启用)的全部工具的额度状态 func quotaListForUser(ctx context.Context, user *entity.Users) ([]v1.QuotaToolOut, error) { - records, err := g.Model(consts.TableTools).Where("is_enabled", 1).Order("sort asc").All() + records, err := g.Model(consts.TableTools).Where("is_enabled", 1).WhereNull("deleted_at").Order("sort asc").All() if err != nil { return nil, err } diff --git a/internal/logic/remind.go b/internal/logic/remind.go new file mode 100644 index 0000000..c123ab6 --- /dev/null +++ b/internal/logic/remind.go @@ -0,0 +1,173 @@ +package logic + +import ( + "context" + "strconv" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 笔记提醒扫描(gcron 回调,建议每分钟执行) +// +// 依据:架构 §4.2 图②、§8.6.4。 +// 并发安全核心:条件更新抢占 —— UPDATE notes SET remind_status=2 +// WHERE id=? AND remind_status=1 +// 仅 rowsAffected==1 的实例继续发送,多实例/重入不会重复发送。 +// +// 状态流转(remind_status): +// 1 待提醒 → (抢占) 2 已提醒 → 发送成功:记 remind_sent_at +// → 发送失败/授权不足/模板未配置:置 3 +// 1 待提醒 且超时过久 → 4 已过期 +// ============================================================================ + +// noteRemindScanBatch 单轮扫描上限,避免一次拉太多 +const noteRemindScanBatch = 100 + +// ScanDueReminders 扫描到点的提醒并发送(gcron 回调)。 +func ScanDueReminders(ctx context.Context) { + now := gtime.Now() + + // 1) 先把「过期过久」的待提醒置为已过期(4),避免历史脏数据被无限重扫 + expireHours := noteRemindExpireHours(ctx) + if _, err := g.Model(consts.TableNotes). + Where("remind_status", consts.NoteRemindPending). + WhereLT("remind_at", now.Add(-time.Duration(expireHours)*time.Hour)). + Data(g.Map{"remind_status": consts.NoteRemindExpired, "updated_at": now}). + Update(); err != nil { + g.Log().Warningf(ctx, "[note-remind] 标记过期失败: %v", err) + } + + // 2) 取到点的待提醒 + records, err := g.Model(consts.TableNotes). + Where("remind_status", consts.NoteRemindPending). + WhereLTE("remind_at", now). + OrderAsc("remind_at"). + Limit(noteRemindScanBatch). + All() + if err != nil { + g.Log().Warningf(ctx, "[note-remind] 扫描到点提醒失败: %v", err) + return + } + for _, r := range records { + note := &entity.Notes{} + if err = r.Struct(note); err != nil { + g.Log().Warningf(ctx, "[note-remind] 解析笔记失败: %v", err) + continue + } + processDueRemind(ctx, note) + } +} + +// processDueRemind 处理单条到点提醒:抢占 → 校验 → 扣减授权 → 发送。 +func processDueRemind(ctx context.Context, note *entity.Notes) { + // 条件更新抢占:仅 rowsAffected==1 的实例继续(并发/多实例下只发一次) + result, err := g.Model(consts.TableNotes). + Where("id", note.Id). + Where("remind_status", consts.NoteRemindPending). + Data(g.Map{"remind_status": consts.NoteRemindSent}). + Update() + if err != nil { + g.Log().Warningf(ctx, "[note-remind] 抢占提醒失败 noteId=%d: %v", note.Id, err) + return + } + affected, _ := result.RowsAffected() + if !shouldClaimRemind(affected) { + // 已被其它实例/轮次抢到,跳过(保证「同一提醒只发一次」) + return + } + + // 已抢占。模板未配置 → 降级:不发送,置为发送失败/未发送(站内标记由 remind_status 呈现) + if RemindTemplateId(ctx) == "" { + markRemindUnsent(ctx, note.Id, "模板未配置") + return + } + + // 发送前条件扣减订阅授权次数(>=1 才减) + decResult, err := g.Model(consts.TableNotes). + Where("id", note.Id). + WhereGTE("subscribe_count", 1). + Decrement("subscribe_count", 1) + if err != nil { + markRemindUnsent(ctx, note.Id, "扣减授权异常:"+err.Error()) + return + } + if decAffected, _ := decResult.RowsAffected(); decAffected != 1 { + markRemindUnsent(ctx, note.Id, "订阅授权次数不足") + return + } + + user, err := getUserById(ctx, note.UserId) + if err != nil || user.Openid == "" { + markRemindUnsent(ctx, note.Id, "用户 openid 缺失") + return + } + + if err = SendNoteRemind(ctx, NoteRemindSendReq{ + NoteId: note.Id, + Openid: user.Openid, + Title: note.Title, + Content: note.Content, + Time: formatRemindTime(note.RemindAt), + }); err != nil { + markRemindFailed(ctx, note.Id, err) + return + } + + now := gtime.Now() + if _, err = g.Model(consts.TableNotes).Where("id", note.Id).Data(g.Map{ + "remind_status": consts.NoteRemindSent, + "remind_sent_at": now, + "updated_at": now, + }).Update(); err != nil { + g.Log().Warningf(ctx, "[note-remind] 记录发送时间失败 noteId=%d: %v", note.Id, err) + } + g.Log().Infof(ctx, "[note-remind] 已发送提醒 noteId=%d userId=%d", note.Id, note.UserId) +} + +// markRemindUnsent 未发送(模板未配置 / 授权不足 / 其它前置失败):置 3 + 尝试次数 +1 + 站内标记日志。 +func markRemindUnsent(ctx context.Context, noteId int64, reason string) { + g.Log().Warningf(ctx, "[note-remind] 未发送 noteId=%d 原因=%s", noteId, reason) + _, _ = g.Model(consts.TableNotes).Where("id", noteId).Data(g.Map{ + "remind_status": consts.NoteRemindFailed, + "updated_at": gtime.Now(), + }).Update() + _, _ = g.Model(consts.TableNotes).Where("id", noteId).Increment("remind_attempts", 1) +} + +// markRemindFailed 发送失败:置 3 + 尝试次数 +1,供前端/后台手动重试。 +func markRemindFailed(ctx context.Context, noteId int64, cause error) { + g.Log().Warningf(ctx, "[note-remind] 发送失败 noteId=%d err=%v", noteId, cause) + _, _ = g.Model(consts.TableNotes).Where("id", noteId).Data(g.Map{ + "remind_status": consts.NoteRemindFailed, + "updated_at": gtime.Now(), + }).Update() + _, _ = g.Model(consts.TableNotes).Where("id", noteId).Increment("remind_attempts", 1) +} + +// shouldClaimRemind 条件更新抢占判定:仅当受影响行数为 1 时才继续发送。 +func shouldClaimRemind(rowsAffected int64) bool { + return rowsAffected == 1 +} + +// noteRemindExpireHours 读取提醒过期阈值(小时),settings 未配置时用默认值。 +func noteRemindExpireHours(ctx context.Context) int { + raw := SettingValue(ctx, consts.SettingNoteRemindExpireHours) + hours, err := strconv.Atoi(raw) + if err != nil || hours <= 0 { + return consts.DefaultNoteRemindExpireHours + } + return hours +} + +func formatRemindTime(t *gtime.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("Y-m-d H:i:s") +} diff --git a/internal/logic/seed.go b/internal/logic/seed.go index abf8cd5..363a3d1 100644 --- a/internal/logic/seed.go +++ b/internal/logic/seed.go @@ -74,10 +74,16 @@ func seedAdmin(ctx context.Context) error { return err } -// seedRows 按 uniqueKey 幂等插入(存在即跳过该行) +// seedRows 按 uniqueKey 幂等插入(存在即跳过该行)。 +// +// 🔴 存在性判断必须 `.Unscoped()`:GoFrame v2.10 会给 Model 查询自动追加 `deleted_at IS NULL`。 +// 若不加,被软删的种子行(当前即 tools)会因"查不到"被判为不存在 → 尝试重新 Insert +// → 撞上**未**墓碑化的唯一键(uk tool_key)→ 每次启动刷屏报错。 +// 加 Unscoped 后判"存在"→ 直接跳过,删除继续跨重启保持。 +// (对 modules/levels/quota_packs/settings 等无 deleted_at 列的表,Unscoped 是空操作。) func seedRows(ctx context.Context, table, uniqueKey string, rows []g.Map) error { for _, row := range rows { - count, err := g.Model(table).Where(uniqueKey, row[uniqueKey]).Count() + count, err := g.Model(table).Unscoped().Where(uniqueKey, row[uniqueKey]).Count() if err != nil { return err } @@ -96,6 +102,11 @@ func seedRows(ctx context.Context, table, uniqueKey string, rows []g.Map) error // 与 seedRows 的区别是「已存在的行也会被更新」,适用于配置由代码定义、没有管理端编辑入口的表。 // 有管理端编辑需求的表请继续用 seedRows,避免每次重启覆盖用户在后台的修改。 func upsertRows(ctx context.Context, table, uniqueKey string, rows []g.Map, updateFields []string) error { + // 注意:此处**刻意不加** Unscoped(与上面的 seedRows 不同)。 + // upsertRows 目前只服务 member_plans,而该表**没有** deleted_at 列 → 框架不会拼软删条件 + // → 加不加 Unscoped 等价(空操作)。 + // ⚠️ 若将来给 member_plans 增加软删列:不能盲加 Unscoped —— 那会让下面的 UPDATE 命中 + // "已软删"的行,等于把软删数据"复活",未必是想要的语义,需重新评估。 for _, row := range rows { count, err := g.Model(table).Where(uniqueKey, row[uniqueKey]).Count() if err != nil { @@ -169,6 +180,13 @@ func seedTools() []g.Map { func seedSettings() []g.Map { return []g.Map{ {"k": consts.SettingQuotaPeriodDays, "v": "7", "remark": "全局默认额度重置周期(天)"}, + // ===== 优惠凭证域(迭代-2026-09-20)===== + // 免单道具 ID / 价格:后台可配,**不硬编码**;下单用码/券时改用该道具走支付。 + {"k": consts.SettingFreebieProductId, "v": consts.DefaultFreebieProductId, + "remark": "免单道具 ID(须与 MP 后台【虚拟支付 → 道具管理】逐字符一致)"}, + {"k": consts.SettingFreebiePriceCents, "v": "1", "remark": "免单道具价格(分)"}, + {"k": consts.SettingPromoFailLimitPerMin, "v": "6", "remark": "同一用户 1 分钟内优惠码错误次数上限"}, + {"k": consts.SettingCouponLockTtlSeconds, "v": "900", "remark": "免单券下单占用自动释放时长(秒)"}, } } diff --git a/internal/logic/softdelete.go b/internal/logic/softdelete.go new file mode 100644 index 0000000..6f26e7d --- /dev/null +++ b/internal/logic/softdelete.go @@ -0,0 +1,187 @@ +package logic + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "tool-api/internal/consts" +) + +// ============================================================================ +// 软删除(T18) +// +// 语义:软删除、可恢复;重复删除同一个已删 id 计成功(幂等,不塞进 failures)。 +// +// 唯一键坑与方案:软删后行仍在,唯一索引仍被占用 → 同一 openid / 用户名 / 手机号无法再录入。 +// 故软删时把**唯一列**改写为墓碑值(由主键派生,必然唯一),并把原值存进 +// `deleted_from`(JSON) 以便人工恢复。墓碑值分**短/长**两种:username 必须用长墓碑(>32 字符) +// 以防被人抢注为合法用户名(详见 tombstoneForColumn / tombstoneLong 的说明), +// phone / openid 用短墓碑(列宽或格式约束下无法被抢注)。 +// +// 为什么不用「含 deleted_at 的复合唯一索引」:MySQL 唯一索引把 NULL 视为互不相等, +// 活跃行的 deleted_at 全为 NULL,会导致同一唯一值可存在多条活跃记录,唯一性失效。 +// +// ⚠️ tools 例外:**不**墓碑化 tool_key。原因有二—— +// 1. tool_key 是与小程序端 src/config/tools.ts TOOL_REGISTRY 的双份契约,保持占用更安全; +// 2. 种子 seedRows 按 tool_key「存在即跳过」,若墓碑化会让被删的种子工具在重启时复活。 +// 故软删工具仅置 deleted_at;重新启用请编辑原行(同名会得到「工具标识已存在」的明确提示)。 +// +// 📌 不变量:GoFrame v2.10 对所有 **Model** 查询(select / count / update / delete)**自动追加** +// `deleted_at IS NULL`(按 schema 判定:表里有 `deleted_at`/`delete_at` 列才拼,否则无影响; +// 源码 gdb_model_select.go 的 formatCondition + gdb_model_soft_time.go。Insert 另自动补 +// created_at/updated_at,并把 deleted_at 置 NULL)。**raw `g.DB().Exec/GetAll/GetCount` 不受影响**。 +// 因此: +// ① 需要「连已软删的行一起看」的地方**必须显式 `.Unscoped()`**——否则软删行被静默过滤。典型后果: +// 批量删除的存在性判断(admin_batch.go existingIdSet)误判"不存在"→ 破坏 delete 幂等; +// 种子存在性判断(seed.go seedRows)误判"不存在"→ 尝试重插 → 撞未墓碑化的 uk tool_key → 启动刷屏报错。 +// 以上两处均已加 .Unscoped()。 +// ② 落在 raw SQL 上的软删过滤**必须手写**(如看板 MemberCount/LevelDist、TodayActive), +// 不能指望框架自动加。 +// (这也正是 tools.tool_key 不墓碑化的根因。) +// ============================================================================ + +// tombstonePrefix 墓碑前缀;墓碑值 = 前缀 + 主键,与真实值不冲突且必然唯一。 +const tombstonePrefix = "#del" + +// tombstonePad 长墓碑补尾,把墓碑值撑到 32 字符以上。 +// +// 🔴 为什么必须撑长:users.username 的校验只有 `required|length:3,32`,**没有字符集限制**, +// 因此短墓碑 "#del5" 是**可以被人抢注为合法用户名**的。一旦有人注册了 "#del5", +// 之后软删 id=5 的用户就会把 username 改成 "#del5" → 撞 uk_username 重复键 +// → AdminUserBatch 整体返回 error → 整个批量删除失败(可被定向瘫痪)。 +// 撑长到 >32 后,该字符串在用户名空间里**不可能被注册**,抢注通道关闭。 +const tombstonePad = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // 40 个 x + +// tombstoneShort 普通墓碑。仅用于下列两类列: +// - phone:录入校验要求 6-20 位**纯数字**,"#" 开头天然不可能;且列宽仅 varchar(20),放不下长墓碑; +// - openid:由微信下发、用户不可自选,无法抢注。 +func tombstoneShort(id int64) string { + return fmt.Sprintf("%s%d", tombstonePrefix, id) +} + +// tombstoneLong 长墓碑:恒定 > 32 字符。用于可被用户自由抢注的列(当前仅 username)。 +func tombstoneLong(id int64) string { + return fmt.Sprintf("%s%d%s", tombstonePrefix, id, tombstonePad) +} + +// tombstoneForColumn 按列名选择墓碑长度。**将来新增任何可抢注的唯一列,必须在这里登记为长墓碑。** +func tombstoneForColumn(col string, id int64) string { + if col == "username" { + return tombstoneLong(id) + } + return tombstoneShort(id) +} + +// alreadySoftDeleted 行是否已被软删(deleted_at 非 nil 且非零)。供幂等跳过使用。 +func alreadySoftDeleted(deletedAt *gtime.Time) bool { + return deletedAt != nil && !deletedAt.IsZero() +} + +// deletedFromJSON 组装 deleted_from 列(保存被墓碑化前的唯一键原值,便于恢复)。 +// 空 map 返回 nil(写入 NULL)。 +func deletedFromJSON(orig map[string]string) interface{} { + if len(orig) == 0 { + return nil + } + b, err := json.Marshal(orig) + if err != nil { + return nil + } + return string(b) +} + +// softDeleteSimple 批量软删除(不墓碑化唯一键,仅置 deleted_at)。 +// +// 幂等机制(实测;源码 gdb_model_update.go:51 与 select 用同一个 formatCondition): +// GoFrame 对 Model **UPDATE 也会**自动追加 `deleted_at IS NULL`。重复软删时该条件不匹配 +// 已删行 → UPDATE 影响 0 行且不报错 → **天然幂等**。下面的 `.WhereNull("deleted_at")` +// 与框架自动条件**重复(冗余但无害)**,保留仅作显式兜底与可读性。 +func softDeleteSimple(ctx context.Context, table string, ids []int64) error { + if len(ids) == 0 { + return nil + } + _, err := g.Model(table).WhereIn("id", ids).WhereNull("deleted_at"). + Data(g.Map{"deleted_at": gtime.Now()}).Update() + return err +} + +// softDeleteUniqueRow 单行软删除:置 deleted_at + 墓碑化 tombstones 指定的列 + 记录原值。 +// 幂等机制同 softDeleteSimple:框架对 UPDATE 自动追加 `deleted_at IS NULL`,重复调用影响 0 行。 +// 这里的 `.WhereNull("deleted_at")` 与框架自动条件重复(冗余但无害),保留作显式兜底。 +func softDeleteUniqueRow(ctx context.Context, table string, id int64, tombstones map[string]string, now *gtime.Time) error { + data := g.Map{ + "deleted_at": now, + "deleted_from": deletedFromJSON(tombstones), + } + for col := range tombstones { + data[col] = tombstoneForColumn(col, id) + } + _, err := g.Model(table).Where("id", id).WhereNull("deleted_at").Data(data).Update() + return err +} + +// softDeleteUsers 软删用户:墓碑化 openid(NOT NULL)与非空 username, +// 保证同 openid / 用户名可重新注册;原值写入 deleted_from。 +// +// 幂等:下面的读取 `g.Model(...).All()` 被框架自动追加 `deleted_at IS NULL`, +// 已删行根本不会返回 → 循环体天然跳过(内部 alreadySoftDeleted 是额外兜底,正常不命中)。 +func softDeleteUsers(ctx context.Context, ids []int64) error { + if len(ids) == 0 { + return nil + } + now := gtime.Now() + rows, err := g.Model(consts.TableUsers). + Fields("id, openid, username, deleted_at").WhereIn("id", ids).All() + if err != nil { + return err + } + for _, r := range rows { + if alreadySoftDeleted(r["deleted_at"].GTime()) { + continue // 已删 → 幂等跳过 + } + id := r["id"].Int64() + orig := map[string]string{} + if op := r["openid"].String(); op != "" { + orig["openid"] = op + } + if un := r["username"].String(); un != "" { + orig["username"] = un + } + if err = softDeleteUniqueRow(ctx, consts.TableUsers, id, orig, now); err != nil { + return err + } + } + return nil +} + +// softDeleteEmployees 软删员工:墓碑化 phone,保证同一企业下同手机号可重新录入; +// 原值写入 deleted_from。幂等机制同 softDeleteUsers(读取被框架自动过滤,已删行不返回)。 +func softDeleteEmployees(ctx context.Context, ids []int64) error { + if len(ids) == 0 { + return nil + } + now := gtime.Now() + rows, err := g.Model(consts.TableEmployees). + Fields("id, phone, deleted_at").WhereIn("id", ids).All() + if err != nil { + return err + } + for _, r := range rows { + if alreadySoftDeleted(r["deleted_at"].GTime()) { + continue + } + id := r["id"].Int64() + orig := map[string]string{} + if ph := r["phone"].String(); ph != "" { + orig["phone"] = ph + } + if err = softDeleteUniqueRow(ctx, consts.TableEmployees, id, orig, now); err != nil { + return err + } + } + return nil +} diff --git a/internal/logic/softdelete_it_test.go b/internal/logic/softdelete_it_test.go new file mode 100644 index 0000000..fd06763 --- /dev/null +++ b/internal/logic/softdelete_it_test.go @@ -0,0 +1,426 @@ +package logic + +import ( + "context" + "net/http" + "os" + "strings" + "testing" + + // 测试进程不会执行 main.go 的驱动注册,这里显式引入 MySQL 驱动 + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/admin/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 软删除真实库集成测试(默认跳过;需真实 MySQL) +// +// 运行:$env:IT_DB=1; go test ./internal/logic/ -run TestSoftDelete -count=1 -v +// +// 说明: +// - 连的是 dev 库(默认 mysql:root:root@tcp(127.0.0.1:3306)/nl_toolbox,可用 IT_DB_LINK 覆盖)。 +// 测试进程 cwd 是包目录,GoFrame 未必能找到 manifest/config,故这里显式 SetConfig。 +// - 仅使用带 __probe 前缀 / 极大假 enterprise_id 的临时数据,结束即清理。 +// - 若 users 缺 deleted_at/deleted_from 列或 employees 表不存在,测试会按 migrate 的定义补建, +// 与线上启动迁移一致(幂等)。 +// ============================================================================ + +func requireIT(t *testing.T) { + t.Helper() + if os.Getenv("IT_DB") == "" { + t.Skip("set IT_DB=1 以运行真实库集成测试") + } + link := os.Getenv("IT_DB_LINK") + if link == "" { + link = "mysql:root:root@tcp(127.0.0.1:3306)/nl_toolbox" + } + gdb.SetConfig(gdb.Config{ + "default": gdb.ConfigGroup{ + gdb.ConfigNode{Link: link}, + }, + }) +} + +// ensureUserSoftDeleteCols 确保 users 具备 deleted_at / deleted_from 列(与 migrateColumns 一致)。 +func ensureUserSoftDeleteCols(ctx context.Context, t *testing.T) { + t.Helper() + cols := []struct{ name, ddl string }{ + {"deleted_at", "ALTER TABLE `users` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"}, + {"deleted_from", "ALTER TABLE `users` ADD COLUMN `deleted_from` text COMMENT '软删前唯一键原值(JSON),便于恢复'"}, + } + for _, c := range cols { + exists, err := columnExists(ctx, "users", c.name) + if err != nil { + t.Fatalf("columnExists users.%s: %v", c.name, err) + } + if !exists { + if _, err = g.DB().Exec(ctx, c.ddl); err != nil { + t.Fatalf("add users.%s: %v", c.name, err) + } + } + } +} + +// ensureEmployeesTable 确保 employees 表存在(与 migrateTables 的 DDL 一致)。 +func ensureEmployeesTable(ctx context.Context, t *testing.T) { + t.Helper() + ddl := "CREATE TABLE IF NOT EXISTS `employees` (" + + "`id` bigint unsigned NOT NULL AUTO_INCREMENT," + + "`enterprise_id` bigint NOT NULL," + + "`name` varchar(64) NOT NULL," + + "`phone` varchar(20) NOT NULL," + + "`dept` varchar(64) NOT NULL DEFAULT ''," + + "`remark` varchar(255) NOT NULL DEFAULT ''," + + "`status` tinyint NOT NULL DEFAULT 1," + + "`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," + + "`deleted_from` text COMMENT '软删前 phone 原值(JSON),便于恢复'," + + "`created_at` datetime DEFAULT NULL,`updated_at` datetime DEFAULT NULL," + + "PRIMARY KEY (`id`)," + + "UNIQUE KEY `uk_ent_phone` (`enterprise_id`,`phone`)," + + "KEY `idx_ent_name` (`enterprise_id`,`name`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业员工库(跨活动复用)'" + if _, err := g.DB().Exec(ctx, ddl); err != nil { + t.Fatalf("ensure employees: %v", err) + } +} + +// ensureToolsSoftDeleteCol 确保 tools 具备 deleted_at 列(与 migrateColumns 一致;dev 库未必跑过迁移)。 +func ensureToolsSoftDeleteCol(ctx context.Context, t *testing.T) { + t.Helper() + exists, err := columnExists(ctx, "tools", "deleted_at") + if err != nil { + t.Fatalf("columnExists tools.deleted_at: %v", err) + } + if !exists { + if _, err = g.DB().Exec(ctx, + "ALTER TABLE `tools` ADD COLUMN `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'"); err != nil { + t.Fatalf("add tools.deleted_at: %v", err) + } + } +} + +// TestSoftDeleteUsers_ReRegisterSucceeds 软删用户后:未删列表看不到 + 原 openid/用户名释放 + +// 同 openid/用户名可重新注册 + 重复软删幂等。 +func TestSoftDeleteUsers_ReRegisterSucceeds(t *testing.T) { + requireIT(t) + ctx := context.Background() + ensureUserSoftDeleteCols(ctx, t) + + const openid = "__probe_openid_softdel__" + const username = "__probe_user_softdel__" + var probeId int64 + cleanup := func() { + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid=? OR username=?", openid, username) + if probeId != 0 { + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", probeId) + } + } + cleanup() + defer cleanup() + + id, err := g.Model(consts.TableUsers).Data(g.Map{ + "openid": openid, "username": username, "nickname": "probe", + "level_key": consts.DefaultLevelKey, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert probe user: %v", err) + } + probeId = id + + if err = softDeleteUsers(ctx, []int64{id}); err != nil { + t.Fatalf("softDeleteUsers: %v", err) + } + // 1) 过滤:未删列表(deleted_at IS NULL)查不到 + if n, _ := g.Model(consts.TableUsers).Where("openid", openid).WhereNull("deleted_at").Count(); n != 0 { + t.Fatalf("软删用户仍出现在未删列表: n=%d", n) + } + // 2) 墓碑化:原 openid / username 已释放(不过滤也查不到该原值) + if n, _ := g.Model(consts.TableUsers).Where("openid", openid).Count(); n != 0 { + t.Fatalf("原 openid 仍被占用: n=%d", n) + } + if n, _ := g.Model(consts.TableUsers).Where("username", username).Count(); n != 0 { + t.Fatalf("原 username 仍被占用: n=%d", n) + } + // 3) 重新注册同 openid + username 必须成功 + if _, err = g.Model(consts.TableUsers).Data(g.Map{ + "openid": openid, "username": username, "nickname": "probe2", + "level_key": consts.DefaultLevelKey, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).Insert(); err != nil { + t.Fatalf("同 openid/username 重新注册失败: %v", err) + } + // 4) 幂等:再次软删同一已删 id 不报错 + if err = softDeleteUsers(ctx, []int64{id}); err != nil { + t.Fatalf("重复软删不幂等: %v", err) + } +} + +// TestSoftDeleteEmployees_ReAddSucceeds 软删员工后:同企业同手机号可重新录入(uk_ent_phone 复合唯一)+ 幂等。 +func TestSoftDeleteEmployees_ReAddSucceeds(t *testing.T) { + requireIT(t) + ctx := context.Background() + ensureEmployeesTable(ctx, t) + + const entId int64 = 9223372036854775 // 极大假企业 id,避开真实数据 + const phone = "__probe_phone__" + cleanup := func() { + _, _ = g.DB().Exec(ctx, "DELETE FROM employees WHERE enterprise_id=?", entId) + } + cleanup() + defer cleanup() + + id, err := g.Model(consts.TableEmployees).Data(g.Map{ + "enterprise_id": entId, "name": "probe", "phone": phone, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert probe employee: %v", err) + } + if err = softDeleteEmployees(ctx, []int64{id}); err != nil { + t.Fatalf("softDeleteEmployees: %v", err) + } + // 过滤 + if n, _ := g.Model(consts.TableEmployees).Where("enterprise_id", entId).Where("phone", phone).WhereNull("deleted_at").Count(); n != 0 { + t.Fatalf("软删员工仍在未删列表: n=%d", n) + } + // 同企业同手机号重新录入必须成功 + if _, err = g.Model(consts.TableEmployees).Data(g.Map{ + "enterprise_id": entId, "name": "probe2", "phone": phone, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).Insert(); err != nil { + t.Fatalf("同企业同手机号重新录入失败: %v", err) + } + // 幂等 + if err = softDeleteEmployees(ctx, []int64{id}); err != nil { + t.Fatalf("重复软删不幂等: %v", err) + } +} + +// TestSoftDeleteUsers_UsernameSquattingBlocked 抢注通道关闭的真实库验证(回归修复前的漏洞): +// +// 修复前:软删 victim 会把 username 改成短墓碑 "#del",其长度 5-20, +// 落在 username 的合法区间(required|length:3,32)内,**可被攻击者提前抢注**。 +// 抢注成功后,软删 victim 就会撞 uk_username → AdminUserBatch 整体报错 → 整个批量删除失败。 +// +// 本测试:先让攻击者注册 username="#del"(修复前这能插进去,正说明漏洞真实存在), +// 再软删 victim;断言软删仍成功(长墓碑 >32,攻击者抢不到), +// 且 victim.username 等于长墓碑、deleted_from 保存了原值。 +func TestSoftDeleteUsers_UsernameSquattingBlocked(t *testing.T) { + requireIT(t) + ctx := context.Background() + ensureUserSoftDeleteCols(ctx, t) + + const victimOpenid = "__probe_victim_openid_squat__" + const victimName = "__probe_victim_name_squat__" + const attackerOpenid = "__probe_attacker_openid_squat__" + var victimId, attackerId int64 + cleanup := func() { + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid IN (?,?)", victimOpenid, attackerOpenid) + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE username=?", victimName) + // victim 被软删后 openid/username 已墓碑化,按主键删;同时按 deleted_from 扫历史残留探测行 + if victimId != 0 { + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", victimId) + } + if attackerId != 0 { + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", attackerId) + } + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE deleted_from LIKE ?", "%"+victimOpenid+"%") + } + cleanup() + defer cleanup() + + // 1) 先插 victim,拿到 victimId(短墓碑名依赖它) + id, err := g.Model(consts.TableUsers).Data(g.Map{ + "openid": victimOpenid, "username": victimName, "nickname": "victim", + "level_key": consts.DefaultLevelKey, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert victim: %v", err) + } + victimId = id + + // 2) 攻击者抢注「修复前会生成的短墓碑」——修复前它是合法且能插入的 + squatName := tombstoneShort(victimId) + if len(squatName) < 3 || len(squatName) > 32 { + t.Fatalf("抢注名 %q 长度 %d 不在合法区间,测试前提不成立", squatName, len(squatName)) + } + attackerId, err = g.Model(consts.TableUsers).Data(g.Map{ + "openid": attackerOpenid, "username": squatName, "nickname": "attacker", + "level_key": consts.DefaultLevelKey, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("攻击者抢注短墓碑名 %q 失败(测试前提不成立): %v", squatName, err) + } + + // 3) 软删 victim:修复后必须成功(长墓碑不与攻击者占用的短墓碑相撞) + if err = softDeleteUsers(ctx, []int64{victimId}); err != nil { + t.Fatalf("软删 victim 失败(抢注通道未关闭?): %v", err) + } + + // 4) 直接查库(raw SQL,绕过 ORM 软删过滤):victim.username 必须是长墓碑 + rows, err := g.DB().GetAll(ctx, "SELECT username, deleted_from, deleted_at FROM users WHERE id=?", victimId) + if err != nil { + t.Fatalf("query victim: %v", err) + } + if len(rows) != 1 { + t.Fatalf("victim 行数=%d 期望 1", len(rows)) + } + if got := rows[0]["username"].String(); got != tombstoneLong(victimId) { + t.Fatalf("victim.username=%q 期望长墓碑 %q", got, tombstoneLong(victimId)) + } + // 5) deleted_from 保存原值,便于恢复 + if df := rows[0]["deleted_from"].String(); !strings.Contains(df, victimName) { + t.Fatalf("deleted_from=%q 未保存原 username %q", df, victimName) + } + // 6) ORM 软删过滤:Model 查询看不到已软删 victim(GoFrame v2.10 soft-time 自动过滤) + if n, _ := g.Model(consts.TableUsers).Where("id", victimId).Count(); n != 0 { + t.Fatalf("Model 仍能看到已软删 victim: n=%d", n) + } + // 7) 原 username 已释放:未删用户里查不到该原值 + if n, _ := g.Model(consts.TableUsers).Where("username", victimName).Count(); n != 0 { + t.Fatalf("原 username 仍被占用: n=%d", n) + } +} + +// TestSoftDeleteBatchDelete_Idempotent 幂等契约(回归 existingIdSet 被框架软删过滤的真 bug): +// 对同一批 id **连续 delete 两次**,第二次仍须 success=N / failed=0。 +// +// 修复前:existingIdSet 用 Model 查询,被框架自动追加 `deleted_at IS NULL` → 已软删 id 查不出 +// +// → 落进 missing → 返回 failed,违反「重复删除已删 id 计成功」的冻结契约。 +// +// 修复后:existingIdSet 加 `.Unscoped()`,软删行照常可见 → 计入 ok。 +func TestSoftDeleteBatchDelete_Idempotent(t *testing.T) { + requireIT(t) + // AdminUserBatch 内部会 WriteAudit → CtxAdminId → g.RequestFromCtx(ctx),需要对非 nil 的 + // *ghttp.Request(否则 GetCtxVar 在 nil 上解引用 panic)。这里构造一个最小请求上下文, + // 使该链路可跑(审计是旁路,写失败也仅告警)。 + req := &ghttp.Request{Request: &http.Request{}} + ctx := req.Context() + ensureUserSoftDeleteCols(ctx, t) + ensureEmployeesTable(ctx, t) + ensureToolsSoftDeleteCol(ctx, t) + + // 审计是旁路(WriteAudit 失败仅告警),但若该表存在会留下记录;清理本次产生、admin_id=0 的批量删除审计 + defer func() { + _, _ = g.DB().Exec(ctx, + "DELETE FROM `"+consts.TableAdminAuditLog+"` WHERE admin_id=0 AND action IN ('user.batch_delete','tool.batch_delete','employee.batch_delete')") + }() + + // ---------- users(existingIdSet + softDeleteUsers 路径)---------- + const uOpenid = "__probe_idem_openid__" + const uName = "__probe_idem_name__" + var uid int64 + uCleanup := func() { + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE openid=? OR username=?", uOpenid, uName) + if uid != 0 { + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", uid) + } + _, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE deleted_from LIKE ?", "%"+uOpenid+"%") + } + uCleanup() + defer uCleanup() + + uid, err := g.Model(consts.TableUsers).Data(g.Map{ + "openid": uOpenid, "username": uName, "nickname": "idem", + "level_key": consts.DefaultLevelKey, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert probe user: %v", err) + } + + ur1, err := AdminUserBatch(ctx, &v1.UserBatchReq{Ids: []int64{uid}, Action: "delete"}) + if err != nil { + t.Fatalf("user 第1次 delete: %v", err) + } + if ur1.Success != 1 || ur1.Failed != 0 { + t.Fatalf("user 第1次 delete 期望 success=1/failed=0,实得 %d/%d failures=%+v", ur1.Success, ur1.Failed, ur1.Failures) + } + ur2, err := AdminUserBatch(ctx, &v1.UserBatchReq{Ids: []int64{uid}, Action: "delete"}) + if err != nil { + t.Fatalf("user 第2次 delete: %v", err) + } + if ur2.Success != 1 || ur2.Failed != 0 { + t.Fatalf("user 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v(existingIdSet 漏了 Unscoped?)", + ur2.Success, ur2.Failed, ur2.Failures) + } + + // ---------- tools(existingIdSet + softDeleteSimple 路径)---------- + const toolKey = "__probe_idem_tool__" + var tid int64 + tCleanup := func() { + _, _ = g.DB().Exec(ctx, "DELETE FROM tools WHERE tool_key=?", toolKey) + if tid != 0 { + _, _ = g.DB().Exec(ctx, "DELETE FROM tools WHERE id=?", tid) + } + } + tCleanup() + defer tCleanup() + + tid, err = g.Model(consts.TableTools).Data(g.Map{ + "tool_key": toolKey, "module_key": "image", "name": "idem-probe", + "icon": "picture", "description": "probe", "sort": 999, "is_hot": 0, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert probe tool: %v", err) + } + + tr1, err := AdminToolsBatch(ctx, &v1.ToolsBatchReq{Ids: []int64{tid}, Action: "delete"}) + if err != nil { + t.Fatalf("tool 第1次 delete: %v", err) + } + if tr1.Success != 1 || tr1.Failed != 0 { + t.Fatalf("tool 第1次 delete 期望 success=1/failed=0,实得 %d/%d", tr1.Success, tr1.Failed) + } + tr2, err := AdminToolsBatch(ctx, &v1.ToolsBatchReq{Ids: []int64{tid}, Action: "delete"}) + if err != nil { + t.Fatalf("tool 第2次 delete: %v", err) + } + if tr2.Success != 1 || tr2.Failed != 0 { + t.Fatalf("tool 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v", tr2.Success, tr2.Failed, tr2.Failures) + } + + // ---------- employees(existingIdSet + softDeleteEmployees 路径)---------- + const entId int64 = 9223372036854774 // 极大假企业 id,避开真实数据 + const ePhone = "__probe_idem_phone__" + var eid int64 + eCleanup := func() { + _, _ = g.DB().Exec(ctx, "DELETE FROM employees WHERE enterprise_id=?", entId) + } + eCleanup() + defer eCleanup() + + eid, err = g.Model(consts.TableEmployees).Data(g.Map{ + "enterprise_id": entId, "name": "idem-probe", "phone": ePhone, "status": 1, + "created_at": gtime.Now(), "updated_at": gtime.Now(), + }).InsertAndGetId() + if err != nil { + t.Fatalf("insert probe employee: %v", err) + } + + er1, err := AdminEmployeeBatch(ctx, &v1.EmployeeBatchReq{Ids: []int64{eid}, Action: "delete"}) + if err != nil { + t.Fatalf("employee 第1次 delete: %v", err) + } + if er1.Success != 1 || er1.Failed != 0 { + t.Fatalf("employee 第1次 delete 期望 success=1/failed=0,实得 %d/%d", er1.Success, er1.Failed) + } + er2, err := AdminEmployeeBatch(ctx, &v1.EmployeeBatchReq{Ids: []int64{eid}, Action: "delete"}) + if err != nil { + t.Fatalf("employee 第2次 delete: %v", err) + } + if er2.Success != 1 || er2.Failed != 0 { + t.Fatalf("employee 重复 delete 必须幂等 success=1/failed=0,实得 %d/%d failures=%+v", er2.Success, er2.Failed, er2.Failures) + } +} diff --git a/internal/logic/softdelete_test.go b/internal/logic/softdelete_test.go new file mode 100644 index 0000000..781c3f2 --- /dev/null +++ b/internal/logic/softdelete_test.go @@ -0,0 +1,106 @@ +package logic + +import ( + "strings" + "testing" + + "github.com/gogf/gf/v2/os/gtime" +) + +// TestTombstoneValue 墓碑值由主键派生(前缀 + id):不同 id 必然不同、且以墓碑前缀开头, +// 保证与真实值空间不相交(真实 openid/手机号不会以 "#" 开头)。 +func TestTombstoneValue(t *testing.T) { + if got := tombstoneShort(7); got != "#del7" { + t.Fatalf("tombstoneShort(7)=%q want %q", got, "#del7") + } + if tombstoneShort(1) == tombstoneShort(2) { + t.Fatal("不同 id 的墓碑值必须不同") + } + if !strings.HasPrefix(tombstoneShort(123), tombstonePrefix) { + t.Fatal("墓碑值必须以墓碑前缀开头") + } + if !strings.HasPrefix(tombstoneLong(123), tombstonePrefix) { + t.Fatal("长墓碑值必须以墓碑前缀开头") + } + if tombstoneLong(1) == tombstoneLong(2) { + t.Fatal("不同 id 的长墓碑值必须不同") + } +} + +// TestTombstoneLong_Length 长墓碑长度必须 > 32(用户名空间外)且 <= 64(放得进 varchar(64))。 +func TestTombstoneLong_Length(t *testing.T) { + for _, id := range []int64{1, 5, 999, 9223372036854775} { + n := len(tombstoneLong(id)) + if n <= 32 { + t.Fatalf("tombstoneLong(%d) 长度=%d,必须 >32 否则可被抢注", id, n) + } + if n > 64 { + t.Fatalf("tombstoneLong(%d) 长度=%d,必须 <=64 否则写不进 varchar(64)", id, n) + } + } +} + +// TestTombstoneForColumn username 走长墓碑(可被抢注列),phone / openid 走短墓碑。 +func TestTombstoneForColumn(t *testing.T) { + if got := tombstoneForColumn("username", 5); got != tombstoneLong(5) { + t.Fatalf("username 应使用长墓碑,got %q", got) + } + if got := tombstoneForColumn("phone", 5); got != tombstoneShort(5) { + t.Fatalf("phone 应使用短墓碑,got %q", got) + } + if got := tombstoneForColumn("openid", 5); got != tombstoneShort(5) { + t.Fatalf("openid 应使用短墓碑,got %q", got) + } +} + +// TestTombstoneLong_NotSquattable 抢注通道关闭证明:任何**合法**用户名(长度 3-32, +// 即 username 校验 required|length:3,32 允许的范围)都不可能等于任何长墓碑值。 +func TestTombstoneLong_NotSquattable(t *testing.T) { + longs := map[string]bool{} + for _, id := range []int64{1, 5, 42, 999, 9223372036854775} { + longs[tombstoneLong(id)] = true + } + // 枚举长度 3-32 的一组候选名,其中尤其包含「短墓碑名」——短墓碑名长度=len("#del5")=5,在合法区间内, + // 但长墓碑长度 >32,绝不可能命中。 + candidates := []string{"abc", "abcde", "#del5", "#del42", "#del999", strings.Repeat("a", 32)} + for _, name := range candidates { + if len(name) < 3 || len(name) > 32 { + t.Fatalf("候选名 %q 不在合法区间 3-32", name) + } + if longs[name] { + t.Fatalf("合法用户名 %q 命中长墓碑,抢注通道未关闭", name) + } + } +} + +// TestDeletedFromJSON 原值快照:空 map / nil → nil(写 NULL);非空 → JSON 字符串且含原值。 +func TestDeletedFromJSON(t *testing.T) { + if v := deletedFromJSON(map[string]string{}); v != nil { + t.Fatalf("空 map 应返回 nil,got %v", v) + } + if v := deletedFromJSON(nil); v != nil { + t.Fatalf("nil 应返回 nil,got %v", v) + } + v := deletedFromJSON(map[string]string{"phone": "13800000000"}) + s, ok := v.(string) + if !ok { + t.Fatalf("非空应返回 string,got %T", v) + } + if !strings.Contains(s, "13800000000") || !strings.Contains(s, "phone") { + t.Fatalf("JSON 应包含原值,got %s", s) + } +} + +// TestAlreadySoftDeleted 幂等跳过判定:NULL / 零值时间视为未删;非零时间视为已删。 +func TestAlreadySoftDeleted(t *testing.T) { + if alreadySoftDeleted(nil) { + t.Fatal("nil 应视为未删") + } + var zero gtime.Time + if alreadySoftDeleted(&zero) { + t.Fatal("零值时间应视为未删") + } + if !alreadySoftDeleted(gtime.Now()) { + t.Fatal("非零时间应视为已删") + } +} diff --git a/internal/logic/tools.go b/internal/logic/tools.go index a5fe0f2..6083def 100644 --- a/internal/logic/tools.go +++ b/internal/logic/tools.go @@ -33,7 +33,7 @@ func ToolsList(ctx context.Context) (*v1.ToolsListRes, error) { return nil, err } - toolRecords, err := g.Model(consts.TableTools).Where("is_enabled", 1).Order("sort asc").All() + toolRecords, err := g.Model(consts.TableTools).Where("is_enabled", 1).WhereNull("deleted_at").Order("sort asc").All() if err != nil { return nil, err } @@ -109,7 +109,7 @@ func ToolsUsed(ctx context.Context, toolKey string) (*v1.ToolsUsedRes, error) { if err != nil { return nil, err } - tool, err := g.Model(consts.TableTools).Where("tool_key", toolKey).Where("is_enabled", 1).One() + tool, err := g.Model(consts.TableTools).Where("tool_key", toolKey).Where("is_enabled", 1).WhereNull("deleted_at").One() if err != nil { return nil, err } @@ -153,7 +153,7 @@ func WorkbenchGet(ctx context.Context) (*v1.WorkbenchGetRes, error) { if err != nil { return nil, err } - hot, err := g.Model(consts.TableTools).Where("is_enabled", 1).Where("is_hot", 1).Order("sort asc").All() + hot, err := g.Model(consts.TableTools).Where("is_enabled", 1).Where("is_hot", 1).WhereNull("deleted_at").Order("sort asc").All() if err != nil { return nil, err } @@ -180,7 +180,7 @@ func WorkbenchSave(ctx context.Context, toolKeys []string) (*v1.WorkbenchSaveRes return nil, gerror.New("工作台至少保留一个工具") } for _, key := range toolKeys { - tool, err := g.Model(consts.TableTools).Where("tool_key", key).Where("is_enabled", 1).One() + tool, err := g.Model(consts.TableTools).Where("tool_key", key).Where("is_enabled", 1).WhereNull("deleted_at").One() if err != nil { return nil, err } diff --git a/internal/logic/upload.go b/internal/logic/upload.go new file mode 100644 index 0000000..197f7be --- /dev/null +++ b/internal/logic/upload.go @@ -0,0 +1,163 @@ +package logic + +import ( + "context" + "crypto/md5" + "encoding/hex" + "io" + "path/filepath" + "strings" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/os/gtime" + + v1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 头像上传(N3 / §2.2) +// +// 本机静态目录方案(U1 已裁定接受):落盘 /avatar//., +// 返回「浏览器可直接打开」的绝对 URL。 +// +// 已知限制:单实例部署正常;多实例(水平扩容)下非落盘实例会 404 —— 上线前须换对象存储。 +// +// ⚠️ 目录常量 StaticDir 导出给 cmd 包:AddStaticPath 在目录不存在时**直接 FATAL 退出、不降级**, +// 必须由 cmd.go 启动期先 gfile.Mkdir 再注册静态路由(与 QrCodeDir 同一套坑与治法)。 +// ============================================================================ + +// StaticDir 头像/静态资源落盘目录(相对服务运行目录)。导出给 cmd 包做启动期建目录(单一来源)。 +const StaticDir = "manifest/static" + +// staticURLPrefix 静态资源对外 URL 前缀(由 cmd.go 的 s.AddStaticPath 提供)。 +const staticURLPrefix = "/static" + +// AvatarMaxBytes 头像体积上限(5MB)。 +const AvatarMaxBytes = 5 * 1024 * 1024 + +// avatarAllowedExt 允许的图片扩展名(小写,含点)。 +var avatarAllowedExt = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".webp": true, +} + +// errUploadInvalid 4016:上传校验失败(体积 / 格式)。 +func errUploadInvalid(msg string) error { + return gerror.NewCode(gcode.New(consts.CodeUploadInvalid, "", nil), msg) +} + +// errUploadFailed 4017:上传存储失败(写盘 / 目录异常),给可读中文提示,绝不静默失败。 +func errUploadFailed(msg string) error { + return gerror.NewCode(gcode.New(consts.CodeUploadFailed, "", nil), msg) +} + +// AvatarUploadFromRequest 处理 multipart 头像上传:取 file 字段 → 校验 → 落盘 → 返回可直开 URL。 +// +// multipart 表单文件字段名固定为 `file`(跨端契约)。 +func AvatarUploadFromRequest(ctx context.Context) (*v1.AvatarUploadRes, error) { + req := g.RequestFromCtx(ctx) + if req == nil { + return nil, errUploadFailed("头像上传失败,请重试") + } + file := req.GetUploadFile("file") + if file == nil { + return nil, errUploadInvalid("请选择要上传的图片") + } + f, err := file.Open() + if err != nil { + return nil, errUploadFailed("头像上传失败,请重试") + } + defer func() { _ = f.Close() }() + // 多读 1 字节用于判定「超过上限」,避免把超大文件全读进内存。 + data, err := io.ReadAll(io.LimitReader(f, AvatarMaxBytes+1)) + if err != nil { + return nil, errUploadFailed("头像上传失败,请重试") + } + return AvatarUpload(ctx, file.Filename, data) +} + +// AvatarUpload 校验并落盘头像,返回「浏览器可直接打开」的绝对 URL。 +// +// 落盘名用内容 md5 → 同一张图重复上传天然去重,且文件名不含用户输入(无路径穿越风险)。 +func AvatarUpload(ctx context.Context, fileName string, data []byte) (*v1.AvatarUploadRes, error) { + if err := validateAvatarUpload(fileName, data); err != nil { + return nil, err + } + sum := md5.Sum(data) + name := hex.EncodeToString(sum[:]) + strings.ToLower(filepath.Ext(fileName)) + ym := gtime.Now().Format("Ym") + relDir := filepath.Join(StaticDir, "avatar", ym) + if err := gfile.Mkdir(relDir); err != nil { + g.Log().Errorf(ctx, "[upload] 创建头像目录失败 dir=%s err=%v", relDir, err) + return nil, errUploadFailed("头像上传失败,请重试") + } + if err := gfile.PutBytes(filepath.Join(relDir, name), data); err != nil { + g.Log().Errorf(ctx, "[upload] 写入头像文件失败 dir=%s name=%s err=%v", relDir, name, err) + return nil, errUploadFailed("头像上传失败,请重试") + } + urlPath := staticURLPrefix + "/avatar/" + ym + "/" + name + return &v1.AvatarUploadRes{AvatarUrl: absoluteStaticURL(ctx, urlPath)}, nil +} + +// validateAvatarUpload 头像上传校验(纯函数,可单测):非空 + 体积 ≤5MB + 扩展名合法 + 内容确为图片。 +func validateAvatarUpload(fileName string, data []byte) error { + if len(data) == 0 { + return errUploadInvalid("上传内容为空") + } + if len(data) > AvatarMaxBytes { + return errUploadInvalid("头像不能超过 5MB") + } + ext := strings.ToLower(filepath.Ext(fileName)) + if !avatarAllowedExt[ext] { + return errUploadInvalid("仅支持 jpg / png / webp 格式的图片") + } + if !isImageBytes(data) { + return errUploadInvalid("文件不是有效的图片") + } + return nil +} + +// isImageBytes 依「魔数」判定是否为受支持的图片(JPEG / PNG / WEBP)。 +// +// 仅看扩展名可被伪造(把 .txt 改名成 .jpg),故再校验内容头,双保险。 +func isImageBytes(b []byte) bool { + if len(b) < 12 { + return false + } + // JPEG: FF D8 FF + if b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF { + return true + } + // PNG: 89 50 4E 47 + if b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47 { + return true + } + // WEBP: "RIFF"...."WEBP" + if string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP" { + return true + } + return false +} + +// absoluteStaticURL 拼成「浏览器可直接打开」的绝对 URL(://)。 +// 无请求上下文(如单测)时回退为相对路径。 +func absoluteStaticURL(ctx context.Context, urlPath string) string { + req := g.RequestFromCtx(ctx) + if req == nil || req.Host == "" { + return urlPath + } + scheme := "http" + if req.TLS != nil { + scheme = "https" + } + if proto := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")); proto != "" { + scheme = proto + } + return scheme + "://" + req.Host + urlPath +} diff --git a/internal/logic/upload_test.go b/internal/logic/upload_test.go new file mode 100644 index 0000000..bd08a0f --- /dev/null +++ b/internal/logic/upload_test.go @@ -0,0 +1,91 @@ +package logic + +import ( + "testing" + + "tool-api/internal/consts" +) + +// ============================================================================ +// 头像上传校验单测(T02.4) +// +// 覆盖:体积 / 扩展名 / 内容魔数校验分支不依赖 DB;4017 必带可读中文提示。 +// ============================================================================ + +func pngBytes() []byte { + b := make([]byte, 16) + copy(b, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) + return b +} + +func jpgBytes() []byte { + b := make([]byte, 16) + copy(b, []byte{0xFF, 0xD8, 0xFF, 0xE0}) + return b +} + +func webpBytes() []byte { + b := make([]byte, 16) + copy(b[0:4], []byte("RIFF")) + copy(b[8:12], []byte("WEBP")) + return b +} + +func TestIsImageBytes(t *testing.T) { + cases := []struct { + name string + data []byte + want bool + }{ + {"png", pngBytes(), true}, + {"jpg", jpgBytes(), true}, + {"webp", webpBytes(), true}, + {"纯文本", []byte("hello world!!!"), false}, + {"过短", []byte{0xFF, 0xD8}, false}, + {"空", []byte{}, false}, + } + for _, c := range cases { + if got := isImageBytes(c.data); got != c.want { + t.Errorf("%s: isImageBytes=%v want %v", c.name, got, c.want) + } + } +} + +func TestValidateAvatarUpload(t *testing.T) { + big := make([]byte, AvatarMaxBytes+1) + copy(big, pngBytes()) + + cases := []struct { + name string + file string + data []byte + wantCode int // 0 = 通过 + }{ + {"合法 png", "a.png", pngBytes(), 0}, + {"合法 jpg(大写扩展名)", "a.JPG", jpgBytes(), 0}, + {"合法 jpeg", "a.jpeg", jpgBytes(), 0}, + {"合法 webp", "a.webp", webpBytes(), 0}, + {"空内容", "a.png", []byte{}, consts.CodeUploadInvalid}, + {"超 5MB", "a.png", big, consts.CodeUploadInvalid}, + {"扩展名不支持", "a.gif", pngBytes(), consts.CodeUploadInvalid}, + {"无扩展名", "a", pngBytes(), consts.CodeUploadInvalid}, + {"内容非图片", "a.jpg", []byte("not an image at all"), consts.CodeUploadInvalid}, + } + for _, c := range cases { + err := validateAvatarUpload(c.file, c.data) + if got := codeOf(err); got != c.wantCode { + t.Errorf("%s: code=%d want %d (err=%v)", c.name, got, c.wantCode, err) + } + } +} + +// TestUploadFailedReadable 4017 必须是可读中文提示、不得静默失败(U1)。 +func TestUploadFailedReadable(t *testing.T) { + err := errUploadFailed("头像上传失败,请重试") + if codeOf(err) != consts.CodeUploadFailed { + t.Fatalf("上传失败应返回 4017,实得 code=%d", codeOf(err)) + } + if err.Error() == "" { + t.Fatalf("4017 必须带可读提示,不得为空") + } +} diff --git a/internal/logic/user.go b/internal/logic/user.go index b9ab9f6..1a3570c 100644 --- a/internal/logic/user.go +++ b/internal/logic/user.go @@ -140,7 +140,8 @@ func loginByOpenid(ctx context.Context, openid, sessionKey string) (*v1.WxLoginR ); err != nil { return nil, err } - record, err := g.Model(consts.TableUsers).Where("openid", openid).One() + // 软删除:已删用户 openid 已墓碑化,不会命中;同 openid 重新登录走上面的 INSERT 新建账号 + record, err := g.Model(consts.TableUsers).Where("openid", openid).WhereNull("deleted_at").One() if err != nil { return nil, err } @@ -163,7 +164,8 @@ func placeholderOpenid() string { // Register H5 独立账号注册(注册即登录) func Register(ctx context.Context, username, password, nickname string) (*v1.WxLoginRes, error) { - count, err := g.Model(consts.TableUsers).Where("username", username).Count() + // 软删除:已删用户 username 已墓碑化,不占用新注册 + count, err := g.Model(consts.TableUsers).Where("username", username).WhereNull("deleted_at").Count() if err != nil { return nil, err } @@ -199,7 +201,7 @@ func AccountLogin(ctx context.Context, username, password string) (*v1.WxLoginRe } func loginByUsername(ctx context.Context, username, password string) (*v1.WxLoginRes, error) { - record, err := g.Model(consts.TableUsers).Where("username", username).One() + record, err := g.Model(consts.TableUsers).Where("username", username).WhereNull("deleted_at").One() if err != nil { return nil, err } @@ -229,7 +231,7 @@ func BindAccount(ctx context.Context, username, password string) (*v1.BindAccoun if err != nil { return nil, err } - record, err := g.Model(consts.TableUsers).Where("username", username).One() + record, err := g.Model(consts.TableUsers).Where("username", username).WhereNull("deleted_at").One() if err != nil { return nil, err } @@ -348,7 +350,7 @@ func loginResult(ctx context.Context, user *entity.Users) (*v1.WxLoginRes, error } func getUserById(ctx context.Context, id int64) (*entity.Users, error) { - record, err := g.Model(consts.TableUsers).Where("id", id).One() + record, err := g.Model(consts.TableUsers).Where("id", id).WhereNull("deleted_at").One() if err != nil { return nil, err } diff --git a/internal/logic/user_detail.go b/internal/logic/user_detail.go new file mode 100644 index 0000000..17c81e8 --- /dev/null +++ b/internal/logic/user_detail.go @@ -0,0 +1,223 @@ +package logic + +import ( + "context" + "encoding/json" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" + "tool-api/internal/model/entity" +) + +// ============================================================================ +// 管理端「用户详情抽屉」聚合(A-new1):GET /user/detail +// +// 一次拉全:基础信息 / 额度 / 订单 / 笔记(仅标题·时间·提醒态) / 等级变更 / 优惠券。 +// 笔记**不含正文**(只读抽屉,避免把用户隐私正文带到后台)。 +// ============================================================================ + +// AdminUserDetail 用户详情聚合 +func AdminUserDetail(ctx context.Context, userId int64) (*adminv1.UserDetailRes, error) { + record, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One() + if err != nil { + return nil, err + } + if record.IsEmpty() { + return nil, gerror.New("用户不存在") + } + user := &entity.Users{} + if err = record.Struct(user); err != nil { + return nil, err + } + + res := &adminv1.UserDetailRes{ + User: buildUserDetailBase(ctx, user), + Quotas: []userv1.QuotaToolOut{}, + Orders: []adminv1.OrderBrief{}, + Notes: []adminv1.NoteBrief{}, + LevelLogs: []adminv1.LevelLogItem{}, + Coupons: []userv1.UserCouponItem{}, + } + + // 额度 + if quotas, qErr := quotaListForUser(ctx, user); qErr == nil { + res.Quotas = quotas + } + // 订单 + if orders, oErr := userDetailOrders(ctx, userId); oErr == nil { + res.Orders = orders + } + // 笔记(仅标题/时间/提醒态) + if notes, nErr := userDetailNotes(ctx, userId); nErr == nil { + res.Notes = notes + } + // 等级变更(操作审计) + if logs, lErr := userDetailLevelLogs(ctx, userId); lErr == nil { + res.LevelLogs = logs + } + // 优惠券(读取态) + if coupons, cErr := userDetailCoupons(ctx, userId); cErr == nil { + res.Coupons = coupons + } + return res, nil +} + +func buildUserDetailBase(ctx context.Context, user *entity.Users) adminv1.UserDetailBase { + base := adminv1.UserDetailBase{ + Id: user.Id, + Nickname: user.Nickname, + AvatarUrl: user.AvatarUrl, + Openid: user.Openid, + LevelKey: user.LevelKey, + Status: user.Status, + CreatedAt: timeStr(user.CreatedAt), + } + if names, err := levelNameMap(ctx); err == nil { + base.LevelName = names[user.LevelKey] + } + if user.LevelExpireAt != nil && !user.LevelExpireAt.IsZero() { + base.LevelExpireAt = timeStr(user.LevelExpireAt) + } + if c, err := g.Model(consts.TableUsageLogs).Where("user_id", user.Id).Count(); err == nil { + base.UsageCount = int64(c) + } + return base +} + +func userDetailOrders(ctx context.Context, userId int64) ([]adminv1.OrderBrief, error) { + records, err := g.Model(consts.TableMemberOrders). + Where("user_id", userId).OrderDesc("id").Limit(20).All() + if err != nil { + return nil, err + } + planNames, _ := planNameMap(ctx) + packNames, _ := quotaPackNameMap(ctx) + toolNames, _ := toolNameMap(ctx) + if toolNames == nil { + toolNames = map[string]string{} + } + list := make([]adminv1.OrderBrief, 0, len(records)) + for _, r := range records { + orderType := r["order_type"].Int() + if orderType == 0 { + orderType = consts.OrderTypeMember + } + title := planNames[r["plan_key"].String()] + if orderType == consts.OrderTypeQuota { + title = packNames[r["pack_key"].String()] + if tn := toolNames[r["tool_key"].String()]; tn != "" && title != "" { + title = tn + " · " + title + } + } + list = append(list, adminv1.OrderBrief{ + OutTradeNo: r["out_trade_no"].String(), + Title: title, + OrderType: orderType, + OriginPriceCents: r["origin_price_cents"].Int64(), + PaidPriceCents: r["paid_price_cents"].Int64(), + DiscountCents: r["discount_cents"].Int64(), + PromoKind: r["promo_kind"].Int(), + Status: r["status"].Int(), + StatusText: orderStatusText(r["status"].Int()), + CreatedAt: timeStr(r["created_at"].GTime()), + DeliveredAt: timeStr(r["delivered_at"].GTime()), + }) + } + return list, nil +} + +func userDetailNotes(ctx context.Context, userId int64) ([]adminv1.NoteBrief, error) { + records, err := g.Model(consts.TableNotes). + Where("user_id", userId).WhereNull("deleted_at").OrderDesc("id").Limit(20).All() + if err != nil { + return nil, err + } + list := make([]adminv1.NoteBrief, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.NoteBrief{ + Id: r["id"].Int64(), + Title: r["title"].String(), + RemindStatus: r["remind_status"].Int(), + RemindAt: timeStr(r["remind_at"].GTime()), + CreatedAt: timeStr(r["created_at"].GTime()), + }) + } + return list, nil +} + +func userDetailLevelLogs(ctx context.Context, userId int64) ([]adminv1.LevelLogItem, error) { + records, err := g.Model(consts.TableAdminAuditLog). + Where("action", "user.set_level"). + Where("target_id", auditId(userId)). + OrderDesc("id").Limit(20).All() + if err != nil { + return nil, err + } + list := make([]adminv1.LevelLogItem, 0, len(records)) + for _, r := range records { + list = append(list, adminv1.LevelLogItem{ + At: timeStr(r["created_at"].GTime()), + BeforeLevel: levelKeyFromAuditJSON(r["before_json"].String()), + AfterLevel: levelKeyFromAuditJSON(r["after_json"].String()), + Operator: r["admin_account"].String(), + Remark: r["remark"].String(), + }) + } + return list, nil +} + +// levelKeyFromAuditJSON 从 {"level_key":"v2"} 中提取等级 key(解析失败返回原串)。 +func levelKeyFromAuditJSON(raw string) string { + if raw == "" { + return "" + } + obj := map[string]interface{}{} + if err := json.Unmarshal([]byte(raw), &obj); err != nil { + return "" + } + if v, ok := obj["level_key"]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func userDetailCoupons(ctx context.Context, userId int64) ([]userv1.UserCouponItem, error) { + // expire_at 的过期判定在 SQL 侧(绑定 gtime 参数,与写入同源;TZ-01),读侧不再比较时间。 + records, err := g.DB().GetAll(ctx, + "SELECT uc.*, "+couponExpirePassedExpr+" AS expire_passed "+ + "FROM "+consts.TableUserCoupons+" uc WHERE uc.user_id = ? ORDER BY uc.id DESC LIMIT 50", + gtime.Now(), userId) + if err != nil { + return nil, err + } + list := make([]userv1.UserCouponItem, 0, len(records)) + for _, r := range records { + c := &entity.UserCoupons{} + if err = r.Struct(c); err != nil { + continue + } + eff := effectiveCouponStatus(c, r["expire_passed"].Bool()) + list = append(list, userv1.UserCouponItem{ + Id: c.Id, + CouponId: c.CouponId, + Title: c.CouponTitle, + SubTitle: couponSubTitleOf(ctx, c.CouponId), + CouponType: c.CouponType, + Kind: c.Kind, + Value: c.Value, + ThresholdCents: c.ThresholdCents, + Status: eff, + ExpireAt: timeStr(c.ExpireAt), + GrantedAt: timeStr(c.GrantedAt), + UsedAt: timeStr(c.UsedAt), + }) + } + return list, nil +} diff --git a/internal/logic/wxmsg.go b/internal/logic/wxmsg.go new file mode 100644 index 0000000..f75fb01 --- /dev/null +++ b/internal/logic/wxmsg.go @@ -0,0 +1,101 @@ +package logic + +import ( + "context" + "encoding/json" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// ============================================================================ +// 订阅消息发送(笔记定时提醒) +// +// 依据:PRD-02 §4.1.2、架构 §8.3。 +// - access_token 复用 xpay.go 的 wxAccessToken()(stable_token + 缓存),不重复造; +// - 模板 ID 走配置 wx.subscribe.noteRemindTemplateId; +// - ★ 模板 ID 为空 → 不发送、打 Warning 日志、正常返回(降级),绝不 panic。 +// ============================================================================ + +// noteSubscribeSendPath 订阅消息发送接口路径(B 端接口,需 access_token) +const noteSubscribeSendPath = "/cgi-bin/message/subscribe/send" + +// NoteRemindSendReq 一条提醒的推送参数 +type NoteRemindSendReq struct { + NoteId int64 + Openid string + Title string + Content string + Time string +} + +// RemindTemplateId 读取订阅消息模板 ID;未配置返回空串。 +func RemindTemplateId(ctx context.Context) string { + return g.Cfg().MustGet(ctx, "wx.subscribe.noteRemindTemplateId", "").String() +} + +// SendNoteRemind 发送笔记提醒;从配置解析模板 ID 后交给底层发送。 +// 模板未配置时底层降级(返回 nil,不 panic)。 +func SendNoteRemind(ctx context.Context, req NoteRemindSendReq) error { + return sendNoteRemindWithTemplate(ctx, req, RemindTemplateId(ctx)) +} + +// sendNoteRemindWithTemplate 以指定模板 ID 发送订阅消息。 +// +// tplId 为空是**合法的降级路径**(PRD-02 R6-12):仅告警日志并正常返回 nil, +// 由调用方(remind.go)把笔记置为「发送失败/未发送」并保留站内提醒标记。 +// 单独抽出该函数是为了让降级路径可被单元测试直接覆盖(不依赖真实微信环境 / DB)。 +func sendNoteRemindWithTemplate(ctx context.Context, req NoteRemindSendReq, tplId string) error { + if tplId == "" { + g.Log().Warningf(ctx, + "[note-remind] 未配置订阅消息模板 wx.subscribe.noteRemindTemplateId,降级为站内提醒 noteId=%d", req.NoteId) + return nil + } + if req.Openid == "" { + return gerror.New("用户 openid 为空,无法发送订阅消息") + } + token, err := wxAccessToken(ctx) + if err != nil { + return err + } + + // 模板字段键需与 MP 后台创建的模板一致:thing1 标题 / time2 提醒时间 / thing3 正文摘要。 + // 该映射属需环境化的集成参数(架构 §十 M1),上线前需与模板对齐。 + data := g.Map{ + "thing1": g.Map{"value": clampRunes(req.Title, 20)}, + "time2": g.Map{"value": req.Time}, + "thing3": g.Map{"value": clampRunes(req.Content, 20)}, + } + body := g.Map{ + "touser": req.Openid, + "template_id": tplId, + "page": "pkg-note/list", + "data": data, + } + resp, err := g.Client().Post(ctx, xpayAPIBase+noteSubscribeSendPath+"?access_token="+token, body) + if err != nil { + return gerror.Newf("调用 subscribe/send 失败: %v", err) + } + defer resp.Close() + + var out struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err = json.Unmarshal(resp.ReadAll(), &out); err != nil { + return gerror.Newf("解析 subscribe/send 响应失败: %v", err) + } + if out.ErrCode != 0 { + return gerror.Newf("订阅消息发送失败: %d %s", out.ErrCode, out.ErrMsg) + } + return nil +} + +// clampRunes 按字符数截断(订阅消息 thing 类型上限 20 个字符) +func clampRunes(s string, max int) string { + rs := []rune(s) + if len(rs) <= max { + return s + } + return string(rs[:max]) +} diff --git a/internal/logic/xpay.go b/internal/logic/xpay.go index 0d0c9af..ba789ff 100644 --- a/internal/logic/xpay.go +++ b/internal/logic/xpay.go @@ -274,6 +274,9 @@ type XPayDeliverNotify struct { ProductId string `xml:"ProductId"` Quantity int `xml:"Quantity"` } `xml:"GoodsInfo"` + // Attach 透传数据(下单时 BuildSignData 里带的 attach 原样回传)。 + // ⚠️ 仅作日志/交叉校验,**永不作为发货依据**(约定 S4:发货只读本地订单行)。 + Attach string `xml:"Attach"` } // ParseDeliverNotify 解析发货推送 XML @@ -288,6 +291,11 @@ func ParseDeliverNotify(raw []byte) (*XPayDeliverNotify, error) { if notify.OutTradeNo == "" { return nil, gerror.New("发货推送缺少 OutTradeNo") } + // Attach 仅作日志/交叉校验,**永不作为发货依据**(约定 S4:发货只读本地订单行)。 + // 注意:平台推送在部分场景不携带 Attach,缺失不影响发货。 + g.Log().Infof(context.Background(), + "[xpay] 发货推送 attach=%s outTradeNo=%s productId=%s", + notify.Attach, notify.OutTradeNo, notify.GoodsInfo.ProductId) return notify, nil } diff --git a/internal/model/entity/entity_annual_events.go b/internal/model/entity/entity_annual_events.go new file mode 100644 index 0000000..dd2f01a --- /dev/null +++ b/internal/model/entity/entity_annual_events.go @@ -0,0 +1,21 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// AnnualEvents 年会活动(一场年会 = 一个活动,隶属某企业)。 +// +// Status 采用字符串(draft/published/ended),与 PRD-02 §6.2 字面一致; +// HallLayout 为画布/背景/舞台 JSON;LayoutVersion 为并发编辑 CAS 版本号。 +type AnnualEvents struct { + Id int64 `json:"id"` + EnterpriseId int64 `json:"enterprise_id"` + Title string `json:"title"` + Venue string `json:"venue"` + EventTime *gtime.Time `json:"event_time"` + Status string `json:"status"` // draft / published / ended + HallLayout string `json:"hall_layout"` + CodeUrl string `json:"code_url"` + LayoutVersion int `json:"layout_version"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_coupons.go b/internal/model/entity/entity_coupons.go new file mode 100644 index 0000000..c590742 --- /dev/null +++ b/internal/model/entity/entity_coupons.go @@ -0,0 +1,29 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// Coupons 优惠券模板。 +// +// 券型(CouponType):1 免单券(走支付)/ 2 展示券(仅展示,**新建默认**)。 +// 面额语义(Kind):1 满减 / 2 免单;Value 为面额(分,仅展示用途)。 +// 适用范围(Scope):0 全部 / 1 指定会员套餐 / 2 指定次数包,ScopeKeys 为 JSON 数组。 +// TotalLimit 总发行上限(0=不限),PerUserLimit 单人限领。 +type Coupons struct { + Id int64 `json:"id"` + Title string `json:"title"` + SubTitle string `json:"sub_title"` + CouponType int `json:"coupon_type"` + Kind int `json:"kind"` + Value int `json:"value"` + ThresholdCents int `json:"threshold_cents"` + Scope int `json:"scope"` + ScopeKeys string `json:"scope_keys"` + TotalLimit int `json:"total_limit"` + PerUserLimit int `json:"per_user_limit"` + ValidFrom *gtime.Time `json:"valid_from"` + ValidTo *gtime.Time `json:"valid_to"` + Status int `json:"status"` + Remark string `json:"remark"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_employees.go b/internal/model/entity/entity_employees.go new file mode 100644 index 0000000..bdfc941 --- /dev/null +++ b/internal/model/entity/entity_employees.go @@ -0,0 +1,22 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// Employees 企业员工库(跨活动复用)。 +// +// 去重键为 (EnterpriseId, Phone)(见 migrate 的 UNIQUE uk_ent_phone); +// Name 参与导入时的「同名不同号」冲突检测。 +type Employees struct { + Id int64 `json:"id"` + EnterpriseId int64 `json:"enterprise_id"` + Name string `json:"name"` + Phone string `json:"phone"` // 手机号(入库前去空格) + Dept string `json:"dept"` + Remark string `json:"remark"` + Status int `json:"status"` // 1 正常 / 0 停用 + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` + // DeletedAt 软删除时间,NULL=未删除;DeletedFrom 保存墓碑化前的 phone 原值(JSON),便于恢复。 + DeletedAt *gtime.Time `json:"deleted_at"` + DeletedFrom *string `json:"deleted_from"` +} diff --git a/internal/model/entity/entity_enterprises.go b/internal/model/entity/entity_enterprises.go new file mode 100644 index 0000000..6518d81 --- /dev/null +++ b/internal/model/entity/entity_enterprises.go @@ -0,0 +1,14 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// Enterprises 企业(年会座次的顶层归属,一个企业可办多场年会) +type Enterprises struct { + Id int64 `json:"id"` + Name string `json:"name"` // 企业名称 + Contact string `json:"contact"` + Remark string `json:"remark"` + Status int `json:"status"` // 1 正常 / 0 停用 + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_event_import_logs.go b/internal/model/entity/entity_event_import_logs.go new file mode 100644 index 0000000..1e3a38e --- /dev/null +++ b/internal/model/entity/entity_event_import_logs.go @@ -0,0 +1,27 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// EventImportLogs 员工导入记录/审计(两段式:预览草稿 → 确认入库)。 +// +// 快照防篡改方案:**DB 快照 by id**(不用 HMAC)。 +// 预览时把「原始行 + 逐行分类判定」序列化为 Snapshot 落库(status=draft); +// 确认时凭 Id 从库读回快照,只接受快照内的行 —— 无需密钥、无硬编码密钥隐患。 +type EventImportLogs struct { + Id int64 `json:"id"` + EnterpriseId int64 `json:"enterprise_id"` + EventId int64 `json:"event_id"` // 0=仅入企业库,不纳入某活动 + FileName string `json:"file_name"` + Status string `json:"status"` // draft 预览中 / committed 已入库 / cancelled 已取消 + Token string `json:"token"` // 预览快照令牌(保留字段;确认以 Id 为准) + Snapshot string `json:"snapshot"` // 解析后完整行 + 分类判定 JSON + Total int `json:"total"` + Inserted int `json:"inserted"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Conflict int `json:"conflict"` + Conflicts string `json:"conflicts"` // 人工裁决结果 JSON + Operator string `json:"operator"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_event_participants.go b/internal/model/entity/entity_event_participants.go new file mode 100644 index 0000000..81ea044 --- /dev/null +++ b/internal/model/entity/entity_event_participants.go @@ -0,0 +1,16 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// EventParticipants 活动候场名单(活动 × 员工,多对多;支持跨活动复用)。 +// +// 「未分配」= 在候场名单中、但未绑定任何 event_seats.employee_id 的成员。 +// +// T04 新增:ExcludeAssign 是否不参与自动排座(1=不参与,永不被自动分配,一直留在员工池)。 +type EventParticipants struct { + Id int64 `json:"id"` + EventId int64 `json:"event_id"` + EmployeeId int64 `json:"employee_id"` + ExcludeAssign int `json:"exclude_assign"` // 1=不参与排座 + CreatedAt *gtime.Time `json:"created_at"` +} diff --git a/internal/model/entity/entity_event_seat_queries.go b/internal/model/entity/entity_event_seat_queries.go new file mode 100644 index 0000000..77a0c7a --- /dev/null +++ b/internal/model/entity/entity_event_seat_queries.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// EventSeatQueries 查座审计 + 限流依据(无 Redis 时的权威统计源,架构 §8.6.2)。 +// +// 每次「通过限流闸门」的查询都会落一行(成功/失败都写);限流窗口判定与连续失败冷却 +// 都基于本表的时间窗 COUNT 与最近若干条 result 计算。 +type EventSeatQueries struct { + Id int64 `json:"id"` + EventId int64 `json:"event_id"` + Openid string `json:"openid"` // 有登录态时记录,否则空 + Ip string `json:"ip"` // 客户端 IP + PhoneMasked string `json:"phone_masked"` // 脱敏手机号(138****0000) + Result int `json:"result"` // 0 失败 / 1 成功 + CreatedAt *gtime.Time `json:"created_at"` +} diff --git a/internal/model/entity/entity_event_seats.go b/internal/model/entity/entity_event_seats.go new file mode 100644 index 0000000..909d4cd --- /dev/null +++ b/internal/model/entity/entity_event_seats.go @@ -0,0 +1,18 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// EventSeats 座位(默认编号 1..capacity,顺时针自桌正上方(12 点)起)。 +// +// IsManual 标记该座位号是否被人工改过(决定「自动排座」fill 是否保留); +// EmployeeId 为 NULL 表示空位。去重键 (EventId, TableId, SeatNo)。 +type EventSeats struct { + Id int64 `json:"id"` + EventId int64 `json:"event_id"` // 冗余,便于按活动查座 + TableId int64 `json:"table_id"` + SeatNo int `json:"seat_no"` + IsManual int `json:"is_manual"` // 1 已手工指定编号 / 0 默认 + EmployeeId *int64 `json:"employee_id"` // NULL=空位 + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_event_tables.go b/internal/model/entity/entity_event_tables.go new file mode 100644 index 0000000..a3bbc50 --- /dev/null +++ b/internal/model/entity/entity_event_tables.go @@ -0,0 +1,23 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// EventTables 圆桌(画布上一个可拖拽的圆桌)。 +// +// T04 新增:TableType 桌型(0 普通 / 1 主桌 / 2 签到台 / 3 媒体席 / 4 备用桌 / 9 其他)、 +// Remark 桌备注、ExcludeAuto 是否不参与自动排序(1=不参与)。老数据回退默认值(S14)。 +type EventTables struct { + Id int64 `json:"id"` + EventId int64 `json:"event_id"` + TableNo string `json:"table_no"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Capacity int `json:"capacity"` // 每桌人数,同步生成 1..Capacity 个座位;特殊桌可为 0 + Rotation int `json:"rotation"` + TableType int `json:"table_type"` // 桌型:0普通 1主桌 2签到台 3媒体席 4备用桌 9其他 + Remark string `json:"remark"` // 桌备注 + ExcludeAuto int `json:"exclude_auto"` // 1=不参与自动排序 + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_notes.go b/internal/model/entity/entity_notes.go new file mode 100644 index 0000000..d7837e5 --- /dev/null +++ b/internal/model/entity/entity_notes.go @@ -0,0 +1,31 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// Notes 笔记主体(含定时提醒与订阅授权计数)。 +// +// 额度语义(见 PRD-02 §4.1.1): +// - 免费用户活跃笔记数(DeletedAt IS NULL)上限 50,会员不限量; +// - 新建计入、编辑不计入、删除(软删除)立即释放额度; +// - 会员判定走 logic.EffectiveLevelKey,会员过期即回落免费额度。 +// +// 提醒语义(见 PRD-02 §4.1.2、架构 §4.2): +// - RemindStatus 取值见 consts(0 未设置 / 1 待提醒 / 2 已提醒 / 3 发送失败 / 4 已过期); +// - SubscribeCount 记录「已获授权的可发送次数」,前端 requestSubscribeMessage 成功后累加, +// 发送前条件扣减,不足即降级为站内提醒。 +type Notes struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` // 归属用户 + Title string `json:"title"` // 标题 + Content string `json:"content"` // 正文(长文本,可为空) + Tags string `json:"tags"` // 标签,逗号分隔(便于 LIKE/FIND_IN_SET 筛选) + IsPinned int `json:"is_pinned"` // 置顶:1 是 / 0 否 + RemindAt *gtime.Time `json:"remind_at"` // 提醒时间,NULL=未设置 + RemindStatus int `json:"remind_status"` // 0 未设置 / 1 待提醒 / 2 已提醒 / 3 发送失败 / 4 已过期 + RemindSentAt *gtime.Time `json:"remind_sent_at"` // 实际发送时间 + RemindAttempts int `json:"remind_attempts"` // 发送尝试次数(失败重试上限用) + SubscribeCount int `json:"subscribe_count"` // 已获授权的可发送次数 + DeletedAt *gtime.Time `json:"deleted_at"` // 软删除时间,NULL=未删除(不占额度) + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_promo_codes.go b/internal/model/entity/entity_promo_codes.go new file mode 100644 index 0000000..4a7038c --- /dev/null +++ b/internal/model/entity/entity_promo_codes.go @@ -0,0 +1,30 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// PromoCodes 优惠码(免单/固定价档位,独立于优惠券)。 +// +// 语义: +// - Code 唯一(大写字母+数字,排除易混字符),下单时由用户输入; +// - ProductId / PriceCents 决定「用码后」实际走支付的道具与价格(单位:分); +// - Scope 0 全部 / 1 指定会员套餐 / 2 指定次数包,ScopeKeys 为 JSON 数组; +// - MaxUses 0=不限;PerUserLimit 单人限用次数; +// - UsedCount 在「下单事务内」自增(并发安全依赖行锁)。 +type PromoCodes struct { + Id int64 `json:"id"` + Code string `json:"code"` + ProductId string `json:"product_id"` + PriceCents int64 `json:"price_cents"` + Scope int `json:"scope"` + ScopeKeys string `json:"scope_keys"` + MaxUses int `json:"max_uses"` + UsedCount int `json:"used_count"` + PerUserLimit int `json:"per_user_limit"` + ValidFrom *gtime.Time `json:"valid_from"` + ValidTo *gtime.Time `json:"valid_to"` + Status int `json:"status"` + Remark string `json:"remark"` + CreatedBy string `json:"created_by"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_tools.go b/internal/model/entity/entity_tools.go index 1e06958..d3ddce5 100644 --- a/internal/model/entity/entity_tools.go +++ b/internal/model/entity/entity_tools.go @@ -24,4 +24,6 @@ type Tools struct { UsageCount int64 `json:"usage_count"` CreatedAt *gtime.Time `json:"created_at"` UpdatedAt *gtime.Time `json:"updated_at"` + // DeletedAt 软删除时间,NULL=未删除。工具不墓碑化 tool_key(跨仓契约 + 种子幂等,见 softdelete.go)。 + DeletedAt *gtime.Time `json:"deleted_at"` } diff --git a/internal/model/entity/entity_user_coupons.go b/internal/model/entity/entity_user_coupons.go new file mode 100644 index 0000000..a4be9f1 --- /dev/null +++ b/internal/model/entity/entity_user_coupons.go @@ -0,0 +1,36 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// UserCoupons 用户持有的优惠券(含券面快照 + 占用/核销记录)。 +// +// 快照:CouponTitle / CouponType / Kind / Value / ThresholdCents 在发放时从模板复制, +// 之后模板改名/改型/改面额**不影响已发券**(约定 S8)。 +// +// 状态(Status):0 未使用 / 1 已使用(占用或核销)/ 2 已过期 / 3 已作废。 +// 来源(Source):1 后台发放 / 2 活动领取 / 3 系统赠送。 +// +// 占用语义(免单券):下单时在同一事务内把 Status 置 1、写 UsedOrderNo / UsedBy='system' / +// LockAt;订单取消/超时由 gcron 释放(清 UsedOrderNo、Status 回 0)。 +type UserCoupons struct { + Id int64 `json:"id"` + CouponId int64 `json:"coupon_id"` + UserId int64 `json:"user_id"` + CouponTitle string `json:"coupon_title"` + CouponType int `json:"coupon_type"` + Kind int `json:"kind"` + Value int `json:"value"` + ThresholdCents int `json:"threshold_cents"` + Status int `json:"status"` + Source int `json:"source"` + GrantedBy string `json:"granted_by"` + GrantedAt *gtime.Time `json:"granted_at"` + UsedAt *gtime.Time `json:"used_at"` + ExpireAt *gtime.Time `json:"expire_at"` + UsedOrderNo string `json:"used_order_no"` + UsedBy string `json:"used_by"` + LockAt *gtime.Time `json:"lock_at"` + Remark string `json:"remark"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/internal/model/entity/entity_users.go b/internal/model/entity/entity_users.go index bc44c9e..704464b 100644 --- a/internal/model/entity/entity_users.go +++ b/internal/model/entity/entity_users.go @@ -25,4 +25,7 @@ type Users struct { CreatedAt *gtime.Time `json:"created_at"` LastLoginAt *gtime.Time `json:"last_login_at"` UpdatedAt *gtime.Time `json:"updated_at"` + // DeletedAt 软删除时间,NULL=未删除;DeletedFrom 保存墓碑化前的唯一键原值(JSON),便于恢复。 + DeletedAt *gtime.Time `json:"deleted_at"` + DeletedFrom *string `json:"deleted_from"` } diff --git a/sql/member_orders_refund.sql b/sql/member_orders_refund.sql new file mode 100644 index 0000000..5130fce --- /dev/null +++ b/sql/member_orders_refund.sql @@ -0,0 +1,31 @@ +-- ============================================================================ +-- 订单退款 / 回收所购服务:补充列(幂等,可重复执行) +-- +-- 本迁移与 internal/logic/migrate.go 的 migrateColumns() 内容一致: +-- 服务端启动时 Migrate() 会自动加列(项目约定:升级只需重启服务)。 +-- 本文件供运维在无法重启 / 需要手工核对时使用。 +-- +-- 新增列: +-- member_orders.service_revoked 退款时是否已回收所购服务:0未回收 1已回收 +-- member_orders.refunded_at 退款时间 +-- ============================================================================ + +SET @db := DATABASE(); + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `member_orders` ADD COLUMN `service_revoked` tinyint NOT NULL DEFAULT 0 COMMENT ''退款时是否已回收所购服务:0未回收 1已回收''', + 'SELECT ''member_orders.service_revoked already exists'' AS msg') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'service_revoked' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `member_orders` ADD COLUMN `refunded_at` datetime DEFAULT NULL COMMENT ''退款时间''', + 'SELECT ''member_orders.refunded_at already exists'' AS msg') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'member_orders' AND COLUMN_NAME = 'refunded_at' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;