小程序一些功能更新
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
128
api/admin/v1/coupon.go
Normal 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
427
api/admin/v1/event.go
Normal 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
79
api/admin/v1/order.go
Normal 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
93
api/admin/v1/promo.go
Normal 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"`
|
||||
}
|
||||
75
api/admin/v1/user_detail.go
Normal file
75
api/admin/v1/user_detail.go
Normal 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
96
api/user/v1/event.go
Normal 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"`
|
||||
}
|
||||
@@ -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
85
api/user/v1/note.go
Normal 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
94
api/user/v1/order.go
Normal 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
107
api/user/v1/promo.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
121
internal/cmd/cmd_test.go
Normal 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,无法定位仓库根目录")
|
||||
}
|
||||
@@ -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" // 免费额度
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
24
internal/controller/admin_order.go
Normal file
24
internal/controller/admin_order.go
Normal 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)
|
||||
}
|
||||
68
internal/controller/coupon.go
Normal file
68
internal/controller/coupon.go
Normal 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)
|
||||
}
|
||||
116
internal/controller/event.go
Normal file
116
internal/controller/event.go
Normal 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)
|
||||
}
|
||||
31
internal/controller/event_pub.go
Normal file
31
internal/controller/event_pub.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
46
internal/controller/note.go
Normal file
46
internal/controller/note.go
Normal 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)
|
||||
}
|
||||
55
internal/controller/promo.go
Normal file
55
internal/controller/promo.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
193
internal/logic/admin_account.go
Normal file
193
internal/logic/admin_account.go
Normal 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
|
||||
}
|
||||
142
internal/logic/admin_audit.go
Normal file
142
internal/logic/admin_audit.go
Normal 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
|
||||
}
|
||||
245
internal/logic/admin_batch.go
Normal file
245
internal/logic/admin_batch.go
Normal 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
|
||||
}
|
||||
58
internal/logic/admin_batch_test.go
Normal file
58
internal/logic/admin_batch_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
internal/logic/admin_level_s10.go
Normal file
26
internal/logic/admin_level_s10.go
Normal 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
|
||||
}
|
||||
38
internal/logic/admin_level_s10_test.go
Normal file
38
internal/logic/admin_level_s10_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
internal/logic/admin_level_test.go
Normal file
78
internal/logic/admin_level_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
385
internal/logic/admin_order.go
Normal file
385
internal/logic/admin_order.go
Normal 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
|
||||
}
|
||||
536
internal/logic/admin_order_test.go
Normal file
536
internal/logic/admin_order_test.go
Normal 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)
|
||||
}
|
||||
207
internal/logic/admin_perm.go
Normal file
207
internal/logic/admin_perm.go
Normal 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()
|
||||
}
|
||||
102
internal/logic/admin_perm_test.go
Normal file
102
internal/logic/admin_perm_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
67
internal/logic/admin_test.go
Normal file
67
internal/logic/admin_test.go
Normal 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
813
internal/logic/coupon.go
Normal 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
|
||||
}
|
||||
106
internal/logic/coupon_test.go
Normal file
106
internal/logic/coupon_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
109
internal/logic/event_code.go
Normal file
109
internal/logic/event_code.go
Normal 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, "[")
|
||||
}
|
||||
330
internal/logic/event_enterprise.go
Normal file
330
internal/logic/event_enterprise.go
Normal 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
|
||||
}
|
||||
112
internal/logic/event_exclude_test.go
Normal file
112
internal/logic/event_exclude_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
556
internal/logic/event_import.go
Normal file
556
internal/logic/event_import.go
Normal 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
|
||||
}
|
||||
149
internal/logic/event_import_test.go
Normal file
149
internal/logic/event_import_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1097
internal/logic/event_layout.go
Normal file
1097
internal/logic/event_layout.go
Normal file
File diff suppressed because it is too large
Load Diff
43
internal/logic/event_public.go
Normal file
43
internal/logic/event_public.go
Normal 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
|
||||
}
|
||||
72
internal/logic/event_public_test.go
Normal file
72
internal/logic/event_public_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
433
internal/logic/event_query.go
Normal file
433
internal/logic/event_query.go
Normal 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()
|
||||
}
|
||||
111
internal/logic/event_query_log.go
Normal file
111
internal/logic/event_query_log.go
Normal 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] + "***"
|
||||
}
|
||||
53
internal/logic/event_query_log_test.go
Normal file
53
internal/logic/event_query_log_test.go
Normal 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("非法值应视为全部,不应过滤")
|
||||
}
|
||||
}
|
||||
218
internal/logic/event_query_test.go
Normal file
218
internal/logic/event_query_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
229
internal/logic/event_seat_test.go
Normal file
229
internal/logic/event_seat_test.go
Normal 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 号座位,应报错")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
471
internal/logic/note.go
Normal 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
107
internal/logic/note_test.go
Normal 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
679
internal/logic/order_pay.go
Normal 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
|
||||
})
|
||||
}
|
||||
76
internal/logic/order_pay_owner_test.go
Normal file
76
internal/logic/order_pay_owner_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
396
internal/logic/order_pay_test.go
Normal file
396
internal/logic/order_pay_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
78
internal/logic/order_pay_tz_test.go
Normal file
78
internal/logic/order_pay_tz_test.go
Normal 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
623
internal/logic/promo.go
Normal 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()
|
||||
}
|
||||
158
internal/logic/promo_test.go
Normal file
158
internal/logic/promo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
173
internal/logic/remind.go
Normal 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")
|
||||
}
|
||||
@@ -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": "免单券下单占用自动释放时长(秒)"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
187
internal/logic/softdelete.go
Normal file
187
internal/logic/softdelete.go
Normal 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
|
||||
}
|
||||
426
internal/logic/softdelete_it_test.go
Normal file
426
internal/logic/softdelete_it_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
106
internal/logic/softdelete_test.go
Normal file
106
internal/logic/softdelete_test.go
Normal 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("非零时间应视为已删")
|
||||
}
|
||||
}
|
||||
@@ -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
163
internal/logic/upload.go
Normal 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
|
||||
}
|
||||
91
internal/logic/upload_test.go
Normal file
91
internal/logic/upload_test.go
Normal 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 必须带可读提示,不得为空")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
223
internal/logic/user_detail.go
Normal file
223
internal/logic/user_detail.go
Normal 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
101
internal/logic/wxmsg.go
Normal 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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
21
internal/model/entity/entity_annual_events.go
Normal file
21
internal/model/entity/entity_annual_events.go
Normal 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"`
|
||||
}
|
||||
29
internal/model/entity/entity_coupons.go
Normal file
29
internal/model/entity/entity_coupons.go
Normal 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"`
|
||||
}
|
||||
22
internal/model/entity/entity_employees.go
Normal file
22
internal/model/entity/entity_employees.go
Normal 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"`
|
||||
}
|
||||
14
internal/model/entity/entity_enterprises.go
Normal file
14
internal/model/entity/entity_enterprises.go
Normal 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"`
|
||||
}
|
||||
27
internal/model/entity/entity_event_import_logs.go
Normal file
27
internal/model/entity/entity_event_import_logs.go
Normal 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"`
|
||||
}
|
||||
16
internal/model/entity/entity_event_participants.go
Normal file
16
internal/model/entity/entity_event_participants.go
Normal 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"`
|
||||
}
|
||||
17
internal/model/entity/entity_event_seat_queries.go
Normal file
17
internal/model/entity/entity_event_seat_queries.go
Normal 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"`
|
||||
}
|
||||
18
internal/model/entity/entity_event_seats.go
Normal file
18
internal/model/entity/entity_event_seats.go
Normal 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"`
|
||||
}
|
||||
23
internal/model/entity/entity_event_tables.go
Normal file
23
internal/model/entity/entity_event_tables.go
Normal 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"`
|
||||
}
|
||||
31
internal/model/entity/entity_notes.go
Normal file
31
internal/model/entity/entity_notes.go
Normal 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"`
|
||||
}
|
||||
30
internal/model/entity/entity_promo_codes.go
Normal file
30
internal/model/entity/entity_promo_codes.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
36
internal/model/entity/entity_user_coupons.go
Normal file
36
internal/model/entity/entity_user_coupons.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
31
sql/member_orders_refund.sql
Normal file
31
sql/member_orders_refund.sql
Normal 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;
|
||||
Reference in New Issue
Block a user