小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

2
.gitignore vendored
View File

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

View File

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

128
api/admin/v1/coupon.go Normal file
View File

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

427
api/admin/v1/event.go Normal file
View File

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

79
api/admin/v1/order.go Normal file
View File

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

93
api/admin/v1/promo.go Normal file
View File

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

View File

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

96
api/user/v1/event.go Normal file
View File

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

View File

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

85
api/user/v1/note.go Normal file
View File

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

94
api/user/v1/order.go Normal file
View File

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

107
api/user/v1/promo.go Normal file
View File

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

View File

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

View File

@@ -76,6 +76,18 @@ type ProfileUpdateRes struct {
AvatarUrl string `json:"avatar_url"`
}
// ===== 头像上传(N3)=====
// AvatarUploadReq 头像上传(multipart/form-data,文件字段名 file;≤5MB,jpg / png / webp)。
//
// 返回的 avatar_url 为「浏览器可直接打开」的绝对地址(<scheme>://<host>/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 {

View File

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

121
internal/cmd/cmd_test.go Normal file
View File

@@ -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,无法定位仓库根目录")
}

View File

@@ -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" // 免费额度

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

813
internal/logic/coupon.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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] + "***"
}

View File

@@ -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("非法值应视为全部,不应过滤")
}
}

View File

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

View File

@@ -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 号座位,应报错")
}
}

View File

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

View File

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

View File

@@ -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=不参与排座'"},
}
}

471
internal/logic/note.go Normal file
View File

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

107
internal/logic/note_test.go Normal file
View File

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

679
internal/logic/order_pay.go Normal file
View File

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

View File

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

View File

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

View File

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

623
internal/logic/promo.go Normal file
View File

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

View File

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

View File

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

View File

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

173
internal/logic/remind.go Normal file
View File

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

View File

@@ -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": "免单券下单占用自动释放时长(秒)"},
}
}

View File

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

View File

@@ -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<victimId>",其长度 5-20,
// 落在 username 的合法区间(required|length:3,32)内,**可被攻击者提前抢注**。
// 抢注成功后,软删 victim 就会撞 uk_username → AdminUserBatch 整体报错 → 整个批量删除失败。
//
// 本测试:先让攻击者注册 username="#del<victimId>"(修复前这能插进去,正说明漏洞真实存在),
// 再软删 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)
}
}

View File

@@ -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("非零时间应视为已删")
}
}

View File

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

163
internal/logic/upload.go Normal file
View File

@@ -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 已裁定接受):落盘 <StaticDir>/avatar/<yyyyMM>/<md5>.<ext>,
// 返回「浏览器可直接打开」的绝对 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(<scheme>://<host><path>)。
// 无请求上下文(如单测)时回退为相对路径。
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
}

View File

@@ -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 必须带可读提示,不得为空")
}
}

View File

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

View File

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

101
internal/logic/wxmsg.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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