in_time_scope 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/.rulesync/commands/translate-readme.md +46 -0
- data/{CLAUDE.md → .rulesync/rules/project.md} +23 -7
- data/README.md +104 -221
- data/docs/book.toml +14 -0
- data/docs/de/SUMMARY.md +5 -0
- data/docs/de/index.md +192 -0
- data/docs/de/point-system.md +295 -0
- data/docs/de/user-name-history.md +164 -0
- data/docs/fr/SUMMARY.md +5 -0
- data/docs/fr/index.md +192 -0
- data/docs/fr/point-system.md +295 -0
- data/docs/fr/user-name-history.md +164 -0
- data/docs/ja/SUMMARY.md +5 -0
- data/docs/ja/index.md +192 -0
- data/docs/ja/point-system.md +295 -0
- data/docs/ja/user-name-history.md +164 -0
- data/docs/src/SUMMARY.md +5 -0
- data/docs/src/index.md +194 -0
- data/docs/src/point-system.md +295 -0
- data/docs/src/user-name-history.md +164 -0
- data/docs/zh/SUMMARY.md +5 -0
- data/docs/zh/index.md +192 -0
- data/docs/zh/point-system.md +295 -0
- data/docs/zh/user-name-history.md +164 -0
- data/lib/in_time_scope/class_methods.rb +139 -91
- data/lib/in_time_scope/version.rb +1 -1
- data/rulesync.jsonc +6 -0
- data/sig/in_time_scope.rbs +24 -14
- metadata +25 -2
data/docs/zh/index.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# InTimeScope
|
|
2
|
+
|
|
3
|
+
你是否每次都在 Rails 中写这样的代码?
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# Before
|
|
7
|
+
Event.where("start_at <= ? AND (end_at IS NULL OR end_at > ?)", Time.current, Time.current)
|
|
8
|
+
|
|
9
|
+
# After
|
|
10
|
+
class Event < ActiveRecord::Base
|
|
11
|
+
in_time_scope
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Event.in_time
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
就是这样。一行 DSL,模型中无需原生 SQL。
|
|
18
|
+
|
|
19
|
+
## 为什么使用这个 Gem?
|
|
20
|
+
|
|
21
|
+
这个 Gem 的目的:
|
|
22
|
+
|
|
23
|
+
- **保持时间范围逻辑一致性** - 在整个代码库中统一
|
|
24
|
+
- **避免复制粘贴 SQL** - 防止容易出错的重复代码
|
|
25
|
+
- **让时间成为一等公民的领域概念** - 使用像 `in_time_published` 这样的命名作用域
|
|
26
|
+
- **自动检测可空性** - 从 schema 生成优化的查询
|
|
27
|
+
|
|
28
|
+
## 推荐用途
|
|
29
|
+
|
|
30
|
+
- 具有有效期的 Rails 应用程序
|
|
31
|
+
- 具有 `start_at` / `end_at` 列的模型
|
|
32
|
+
- 希望统一时间逻辑而不使用分散的 `where` 子句的团队
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle add in_time_scope
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 快速入门
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class Event < ActiveRecord::Base
|
|
44
|
+
in_time_scope
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 类作用域
|
|
48
|
+
Event.in_time # 当前有效的记录
|
|
49
|
+
Event.in_time(Time.parse("2024-06-01")) # 特定时间有效的记录
|
|
50
|
+
|
|
51
|
+
# 实例方法
|
|
52
|
+
event.in_time? # 这条记录当前是否有效?
|
|
53
|
+
event.in_time?(some_time) # 在那个时间是否有效?
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 功能
|
|
57
|
+
|
|
58
|
+
### 自动优化的 SQL
|
|
59
|
+
|
|
60
|
+
Gem 读取你的 schema 并生成正确的 SQL:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# 允许 NULL 的列 → NULL 感知查询
|
|
64
|
+
WHERE (start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at > ?)
|
|
65
|
+
|
|
66
|
+
# NOT NULL 列 → 简单查询
|
|
67
|
+
WHERE start_at <= ? AND end_at > ?
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 命名作用域
|
|
71
|
+
|
|
72
|
+
每个模型多个时间窗口:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class Article < ActiveRecord::Base
|
|
76
|
+
in_time_scope :published # → Article.in_time_published
|
|
77
|
+
in_time_scope :featured # → Article.in_time_featured
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 自定义列
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class Campaign < ActiveRecord::Base
|
|
85
|
+
in_time_scope start_at: { column: :available_at },
|
|
86
|
+
end_at: { column: :expired_at }
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 仅开始模式(版本历史)
|
|
91
|
+
|
|
92
|
+
用于每行有效直到下一行的记录:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class Price < ActiveRecord::Base
|
|
96
|
+
in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# 额外好处:使用 NOT EXISTS 的高效 has_one
|
|
100
|
+
class User < ActiveRecord::Base
|
|
101
|
+
has_one :current_price, -> { latest_in_time(:user_id) }, class_name: "Price"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
User.includes(:current_price) # 无 N+1,每个用户只获取最新的
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 仅结束模式(过期)
|
|
108
|
+
|
|
109
|
+
用于在过期之前一直有效的记录:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
class Coupon < ActiveRecord::Base
|
|
113
|
+
in_time_scope start_at: { column: nil }, end_at: { null: false }
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 反向作用域
|
|
118
|
+
|
|
119
|
+
查询时间窗口外的记录:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# 尚未开始的记录 (start_at > time)
|
|
123
|
+
Event.before_in_time
|
|
124
|
+
event.before_in_time?
|
|
125
|
+
|
|
126
|
+
# 已经结束的记录 (end_at <= time)
|
|
127
|
+
Event.after_in_time
|
|
128
|
+
event.after_in_time?
|
|
129
|
+
|
|
130
|
+
# 时间窗口外的记录(开始前或结束后)
|
|
131
|
+
Event.out_of_time
|
|
132
|
+
event.out_of_time? # in_time? 的逻辑反
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
也适用于命名作用域:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
Article.before_in_time_published # 尚未发布
|
|
139
|
+
Article.after_in_time_published # 发布已结束
|
|
140
|
+
Article.out_of_time_published # 当前未发布
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 选项参考
|
|
144
|
+
|
|
145
|
+
| 选项 | 默认值 | 描述 | 示例 |
|
|
146
|
+
| --- | --- | --- | --- |
|
|
147
|
+
| `scope_name`(第一个参数) | `:in_time` | 像 `in_time_published` 这样的命名作用域 | `in_time_scope :published` |
|
|
148
|
+
| `start_at: { column: }` | `:start_at` | 自定义列名,`nil` 禁用 | `start_at: { column: :available_at }` |
|
|
149
|
+
| `end_at: { column: }` | `:end_at` | 自定义列名,`nil` 禁用 | `end_at: { column: nil }` |
|
|
150
|
+
| `start_at: { null: }` | 自动检测 | 强制 NULL 处理 | `start_at: { null: false }` |
|
|
151
|
+
| `end_at: { null: }` | 自动检测 | 强制 NULL 处理 | `end_at: { null: true }` |
|
|
152
|
+
|
|
153
|
+
## 示例
|
|
154
|
+
|
|
155
|
+
- [带有效期的积分系统](./point-system.md) - 完整时间窗口模式
|
|
156
|
+
- [用户名历史](./user-name-history.md) - 仅开始模式
|
|
157
|
+
|
|
158
|
+
## 致谢
|
|
159
|
+
|
|
160
|
+
受 [onk/shibaraku](https://github.com/onk/shibaraku) 启发。这个 Gem 扩展了以下概念:
|
|
161
|
+
|
|
162
|
+
- 用于优化查询的 schema 感知 NULL 处理
|
|
163
|
+
- 每个模型多个命名作用域
|
|
164
|
+
- 仅开始 / 仅结束模式
|
|
165
|
+
- 用于高效 `has_one` 关联的 `latest_in_time` / `earliest_in_time`
|
|
166
|
+
- 反向作用域:`before_in_time`、`after_in_time`、`out_of_time`
|
|
167
|
+
|
|
168
|
+
## 开发
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# 安装依赖
|
|
172
|
+
bin/setup
|
|
173
|
+
|
|
174
|
+
# 运行测试
|
|
175
|
+
bundle exec rspec
|
|
176
|
+
|
|
177
|
+
# 运行 linting
|
|
178
|
+
bundle exec rubocop
|
|
179
|
+
|
|
180
|
+
# 生成 CLAUDE.md(用于 AI 编码助手)
|
|
181
|
+
npx rulesync generate
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
这个项目使用 [rulesync](https://github.com/dyoshikawa/rulesync) 来管理 AI 助手规则。编辑 `.rulesync/rules/*.md` 并运行 `npx rulesync generate` 来更新 `CLAUDE.md`。
|
|
185
|
+
|
|
186
|
+
## 贡献
|
|
187
|
+
|
|
188
|
+
欢迎在 [GitHub](https://github.com/kyohah/in_time_scope) 上提交 bug 报告和 pull request。
|
|
189
|
+
|
|
190
|
+
## 许可证
|
|
191
|
+
|
|
192
|
+
MIT 许可证
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# 带有效期的积分系统示例
|
|
2
|
+
|
|
3
|
+
本示例展示如何使用 `in_time_scope` 实现带有效期的积分系统。积分可以预先发放,在未来某个时间生效,从而完全不需要 cron 任务。
|
|
4
|
+
|
|
5
|
+
另请参阅:[spec/point_system_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/point_system_spec.rb)
|
|
6
|
+
|
|
7
|
+
## 用例
|
|
8
|
+
|
|
9
|
+
- 用户获得带有有效期的积分(开始日期和过期日期)
|
|
10
|
+
- 积分可以预先发放,在未来激活(例如:每月会员奖励)
|
|
11
|
+
- 在任意时间点计算有效积分,无需 cron 任务
|
|
12
|
+
- 查询即将生效的积分、已过期的积分等
|
|
13
|
+
|
|
14
|
+
## 无需 Cron 任务
|
|
15
|
+
|
|
16
|
+
**这是核心功能。** 传统的积分系统是定时任务的噩梦:
|
|
17
|
+
|
|
18
|
+
### 你习惯的 Cron 地狱
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# activate_points_job.rb - 每分钟运行
|
|
22
|
+
class ActivatePointsJob < ApplicationJob
|
|
23
|
+
def perform
|
|
24
|
+
Point.where(status: "pending")
|
|
25
|
+
.where("start_at <= ?", Time.current)
|
|
26
|
+
.update_all(status: "active")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# expire_points_job.rb - 每分钟运行
|
|
31
|
+
class ExpirePointsJob < ApplicationJob
|
|
32
|
+
def perform
|
|
33
|
+
Point.where(status: "active")
|
|
34
|
+
.where("end_at <= ?", Time.current)
|
|
35
|
+
.update_all(status: "expired")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 然后你还需要:
|
|
40
|
+
# - Sidekiq / Delayed Job / Good Job
|
|
41
|
+
# - Redis(用于 Sidekiq)
|
|
42
|
+
# - Cron 或 whenever gem
|
|
43
|
+
# - 任务失败监控
|
|
44
|
+
# - 失败任务的重试逻辑
|
|
45
|
+
# - 防止重复运行的锁机制
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### InTimeScope 的方式
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# 就这样。没有任务。没有 status 列。没有基础设施。
|
|
52
|
+
user.points.in_time.sum(:amount)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**一行代码。零基础设施。永远准确。**
|
|
56
|
+
|
|
57
|
+
### 为什么这样可行
|
|
58
|
+
|
|
59
|
+
`start_at` 和 `end_at` 列就是状态本身。不需要 `status` 列,因为时间比较在查询时进行:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# 这些都不需要后台处理就能工作:
|
|
63
|
+
user.points.in_time # 当前有效
|
|
64
|
+
user.points.in_time(1.month.from_now) # 下个月有效
|
|
65
|
+
user.points.in_time(1.year.ago) # 去年有效(审计!)
|
|
66
|
+
user.points.before_in_time # 待生效(尚未激活)
|
|
67
|
+
user.points.after_in_time # 已过期
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 你消除的东西
|
|
71
|
+
|
|
72
|
+
| 组件 | 基于 Cron 的系统 | InTimeScope |
|
|
73
|
+
|-----------|------------------|-------------|
|
|
74
|
+
| 后台任务库 | 必需 | **不需要** |
|
|
75
|
+
| 任务用的 Redis/数据库 | 必需 | **不需要** |
|
|
76
|
+
| 任务调度器 (cron) | 必需 | **不需要** |
|
|
77
|
+
| Status 列 | 必需 | **不需要** |
|
|
78
|
+
| 更新 status 的迁移 | 必需 | **不需要** |
|
|
79
|
+
| 任务失败监控 | 必需 | **不需要** |
|
|
80
|
+
| 重试逻辑 | 必需 | **不需要** |
|
|
81
|
+
| 竞态条件处理 | 必需 | **不需要** |
|
|
82
|
+
|
|
83
|
+
### 额外好处:免费的时间旅行
|
|
84
|
+
|
|
85
|
+
使用基于 cron 的系统,回答"用户 X 在 1 月 15 日有多少积分?"需要复杂的审计日志或事件溯源。
|
|
86
|
+
|
|
87
|
+
使用 InTimeScope:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
user.points.in_time(Date.parse("2024-01-15").middle_of_day).sum(:amount)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**历史查询直接可用。** 没有额外的表。没有事件溯源。没有复杂性。
|
|
94
|
+
|
|
95
|
+
## Schema
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Migration
|
|
99
|
+
class CreatePoints < ActiveRecord::Migration[7.0]
|
|
100
|
+
def change
|
|
101
|
+
create_table :points do |t|
|
|
102
|
+
t.references :user, null: false, foreign_key: true
|
|
103
|
+
t.integer :amount, null: false
|
|
104
|
+
t.string :reason, null: false
|
|
105
|
+
t.datetime :start_at, null: false # 积分何时可用
|
|
106
|
+
t.datetime :end_at, null: false # 积分何时过期
|
|
107
|
+
t.timestamps
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
add_index :points, [:user_id, :start_at, :end_at]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 模型
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class Point < ApplicationRecord
|
|
119
|
+
belongs_to :user
|
|
120
|
+
|
|
121
|
+
# start_at 和 end_at 都是必需的(完整时间窗口)
|
|
122
|
+
in_time_scope start_at: { null: false }, end_at: { null: false }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class User < ApplicationRecord
|
|
126
|
+
has_many :points
|
|
127
|
+
has_many :in_time_points, -> { in_time }, class_name: "Point"
|
|
128
|
+
|
|
129
|
+
# 发放每月奖励积分(预先安排)
|
|
130
|
+
def grant_monthly_bonus(amount:, months_valid: 6)
|
|
131
|
+
points.create!(
|
|
132
|
+
amount: amount,
|
|
133
|
+
reason: "Monthly membership bonus",
|
|
134
|
+
start_at: 1.month.from_now, # 下个月激活
|
|
135
|
+
end_at: (1 + months_valid).months.from_now
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### `has_many :in_time_points` 的威力
|
|
142
|
+
|
|
143
|
+
这简单的一行解锁了有效积分的 **无 N+1 预加载**:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# 仅用 2 个查询加载 100 个用户及其有效积分
|
|
147
|
+
users = User.includes(:in_time_points).limit(100)
|
|
148
|
+
|
|
149
|
+
users.each do |user|
|
|
150
|
+
# 没有额外查询!已经加载。
|
|
151
|
+
total = user.in_time_points.sum(&:amount)
|
|
152
|
+
puts "#{user.name}: #{total} points"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
没有这个关联,你需要:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# N+1 问题:1 个用户查询 + 100 个积分查询
|
|
160
|
+
users = User.limit(100)
|
|
161
|
+
users.each do |user|
|
|
162
|
+
total = user.points.in_time.sum(:amount) # 每个用户一个查询!
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## 使用方法
|
|
167
|
+
|
|
168
|
+
### 发放不同有效期的积分
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
user = User.find(1)
|
|
172
|
+
|
|
173
|
+
# 即时积分(有效期 1 年)
|
|
174
|
+
user.points.create!(
|
|
175
|
+
amount: 100,
|
|
176
|
+
reason: "Welcome bonus",
|
|
177
|
+
start_at: Time.current,
|
|
178
|
+
end_at: 1.year.from_now
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# 6 个月会员的预安排积分
|
|
182
|
+
# 积分下个月激活,激活后有效 6 个月
|
|
183
|
+
user.grant_monthly_bonus(amount: 500, months_valid: 6)
|
|
184
|
+
|
|
185
|
+
# 活动积分(限时)
|
|
186
|
+
user.points.create!(
|
|
187
|
+
amount: 200,
|
|
188
|
+
reason: "Summer campaign",
|
|
189
|
+
start_at: Date.parse("2024-07-01").beginning_of_day,
|
|
190
|
+
end_at: Date.parse("2024-08-31").end_of_day
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 查询积分
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# 当前有效积分
|
|
198
|
+
user.in_time_member_points.sum(:amount)
|
|
199
|
+
# => 100(只有欢迎奖励当前有效)
|
|
200
|
+
|
|
201
|
+
# 检查下个月将有多少积分可用
|
|
202
|
+
user.in_time_member_points(1.month.from_now).sum(:amount)
|
|
203
|
+
# => 600(欢迎奖励 + 每月奖励)
|
|
204
|
+
|
|
205
|
+
# 待生效积分(已安排但尚未激活)
|
|
206
|
+
user.points.before_in_time.sum(:amount)
|
|
207
|
+
# => 500(等待激活的每月奖励)
|
|
208
|
+
|
|
209
|
+
# 已过期积分
|
|
210
|
+
user.points.after_in_time.sum(:amount)
|
|
211
|
+
|
|
212
|
+
# 所有无效积分(待生效 + 已过期)
|
|
213
|
+
user.points.out_of_time.sum(:amount)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 管理后台查询
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
# 历史审计:特定日期的有效积分
|
|
220
|
+
Point.in_time(Date.parse("2024-01-15").middle_of_day)
|
|
221
|
+
.group(:user_id)
|
|
222
|
+
.sum(:amount)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## 自动会员奖励流程
|
|
226
|
+
|
|
227
|
+
对于 6 个月高级会员,你可以设置定期奖励 **无需 cron、无需 Sidekiq、无需 Redis、无需监控**:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# 当用户注册高级会员时,原子性地创建会员资格和所有奖励
|
|
231
|
+
ActiveRecord::Base.transaction do
|
|
232
|
+
membership = Membership.create!(user: user, plan: "premium_6_months")
|
|
233
|
+
|
|
234
|
+
# 在注册时预先创建所有 6 个月的奖励
|
|
235
|
+
6.times do |month|
|
|
236
|
+
user.points.create!(
|
|
237
|
+
amount: 500,
|
|
238
|
+
reason: "Premium member bonus - Month #{month + 1}",
|
|
239
|
+
start_at: (month + 1).months.from_now,
|
|
240
|
+
end_at: (month + 7).months.from_now # 每个奖励有效 6 个月
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
# => 创建会员资格 + 6 条积分记录,将按月激活
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## 为什么这种设计更优越
|
|
248
|
+
|
|
249
|
+
### 正确性
|
|
250
|
+
|
|
251
|
+
- **没有竞态条件**:Cron 任务可能运行两次、跳过运行或重叠。InTimeScope 查询始终是确定性的。
|
|
252
|
+
- **没有时间漂移**:Cron 按间隔运行(每分钟?每 5 分钟?)。InTimeScope 精确到毫秒。
|
|
253
|
+
- **没有丢失的更新**:任务失败可能使积分处于错误状态。InTimeScope 没有可以被破坏的状态。
|
|
254
|
+
|
|
255
|
+
### 简单性
|
|
256
|
+
|
|
257
|
+
- **无需基础设施**:删除 Sidekiq。删除 Redis。删除任务监控。
|
|
258
|
+
- **无需状态变更迁移**:时间就是状态。不需要 `UPDATE` 语句。
|
|
259
|
+
- **无需调试任务日志**:只需查询数据库就能准确看到发生了什么。
|
|
260
|
+
|
|
261
|
+
### 可测试性
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
# 基于 Cron 的测试很痛苦:
|
|
265
|
+
travel_to 1.month.from_now do
|
|
266
|
+
ActivatePointsJob.perform_now
|
|
267
|
+
ExpirePointsJob.perform_now
|
|
268
|
+
expect(user.points.active.sum(:amount)).to eq(500)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# InTimeScope 测试很简单:
|
|
272
|
+
expect(user.points.in_time(1.month.from_now).sum(:amount)).to eq(500)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### 总结
|
|
276
|
+
|
|
277
|
+
| 方面 | 基于 Cron | InTimeScope |
|
|
278
|
+
|--------|-----------|-------------|
|
|
279
|
+
| 基础设施 | Sidekiq + Redis + Cron | **无** |
|
|
280
|
+
| 积分激活 | 批处理任务(延迟) | **即时** |
|
|
281
|
+
| 历史查询 | 没有审计日志不可能 | **内置** |
|
|
282
|
+
| 时间精度 | 分钟(cron 间隔) | **毫秒** |
|
|
283
|
+
| 调试 | 任务日志 + 数据库 | **仅数据库** |
|
|
284
|
+
| 测试 | 时间旅行 + 运行任务 | **仅查询** |
|
|
285
|
+
| 失败模式 | 多种(任务失败、竞态条件) | **无** |
|
|
286
|
+
|
|
287
|
+
## 提示
|
|
288
|
+
|
|
289
|
+
1. **使用数据库索引** 在 `[user_id, start_at, end_at]` 上以获得最佳性能。
|
|
290
|
+
|
|
291
|
+
2. **在注册时预先发放积分** 而不是安排 cron 任务。
|
|
292
|
+
|
|
293
|
+
3. **使用 `in_time(time)` 进行审计** 以检查任何历史时间点的积分余额。
|
|
294
|
+
|
|
295
|
+
4. **与反向作用域结合** 以构建显示待生效/已过期积分的管理后台。
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# 用户名历史示例
|
|
2
|
+
|
|
3
|
+
本示例展示如何使用 `in_time_scope` 管理用户名历史,使你能够查询任意时间点的用户名。
|
|
4
|
+
|
|
5
|
+
另请参阅:[spec/user_name_history_spec.rb](https://github.com/kyohah/in_time_scope/blob/main/spec/user_name_history_spec.rb)
|
|
6
|
+
|
|
7
|
+
## 用例
|
|
8
|
+
|
|
9
|
+
- 用户可以更改显示名称
|
|
10
|
+
- 你需要保留所有名称变更的历史
|
|
11
|
+
- 你想要检索在特定时间点有效的名称(例如:用于审计日志、历史报告)
|
|
12
|
+
|
|
13
|
+
## Schema
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Migration
|
|
17
|
+
class CreateUserNameHistories < ActiveRecord::Migration[7.0]
|
|
18
|
+
def change
|
|
19
|
+
create_table :users do |t|
|
|
20
|
+
t.string :email, null: false
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
create_table :user_name_histories do |t|
|
|
25
|
+
t.references :user, null: false, foreign_key: true
|
|
26
|
+
t.string :name, null: false
|
|
27
|
+
t.datetime :start_at, null: false # 此名称何时生效
|
|
28
|
+
t.timestamps
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
add_index :user_name_histories, [:user_id, :start_at]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 模型
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class UserNameHistory < ApplicationRecord
|
|
40
|
+
belongs_to :user
|
|
41
|
+
include InTimeScope
|
|
42
|
+
|
|
43
|
+
# 仅开始模式:每条记录从 start_at 到下一条记录有效
|
|
44
|
+
in_time_scope start_at: { null: false }, end_at: { column: nil }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class User < ApplicationRecord
|
|
48
|
+
has_many :user_name_histories
|
|
49
|
+
|
|
50
|
+
# 获取当前名称(已开始的最新记录)
|
|
51
|
+
has_one :current_name_history,
|
|
52
|
+
-> { latest_in_time(:user_id) },
|
|
53
|
+
class_name: "UserNameHistory"
|
|
54
|
+
|
|
55
|
+
# 获取当前名称的便捷方法
|
|
56
|
+
def current_name
|
|
57
|
+
current_name_history&.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 获取特定时间点的名称
|
|
61
|
+
def name_at(time)
|
|
62
|
+
user_name_histories.in_time(time).order(start_at: :desc).first&.name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 使用方法
|
|
68
|
+
|
|
69
|
+
### 创建名称历史
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
user = User.create!(email: "alice@example.com")
|
|
73
|
+
|
|
74
|
+
# 初始名称
|
|
75
|
+
UserNameHistory.create!(
|
|
76
|
+
user: user,
|
|
77
|
+
name: "Alice",
|
|
78
|
+
start_at: Time.parse("2024-01-01")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# 名称变更
|
|
82
|
+
UserNameHistory.create!(
|
|
83
|
+
user: user,
|
|
84
|
+
name: "Alice Smith",
|
|
85
|
+
start_at: Time.parse("2024-06-01")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 另一次名称变更
|
|
89
|
+
UserNameHistory.create!(
|
|
90
|
+
user: user,
|
|
91
|
+
name: "Alice Johnson",
|
|
92
|
+
start_at: Time.parse("2024-09-01")
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 查询名称
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# 当前名称(使用 has_one 和 latest_in_time)
|
|
100
|
+
user.current_name
|
|
101
|
+
# => "Alice Johnson"
|
|
102
|
+
|
|
103
|
+
# 特定时间点的名称
|
|
104
|
+
user.name_at(Time.parse("2024-03-15"))
|
|
105
|
+
# => "Alice"
|
|
106
|
+
|
|
107
|
+
user.name_at(Time.parse("2024-07-15"))
|
|
108
|
+
# => "Alice Smith"
|
|
109
|
+
|
|
110
|
+
user.name_at(Time.parse("2024-10-15"))
|
|
111
|
+
# => "Alice Johnson"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 高效的预加载
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# 加载用户及其当前名称(无 N+1)
|
|
118
|
+
users = User.includes(:current_name_history).limit(100)
|
|
119
|
+
|
|
120
|
+
users.each do |user|
|
|
121
|
+
puts "#{user.email}: #{user.current_name_history&.name}"
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 查询有效记录
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# 所有当前有效的名称记录
|
|
129
|
+
UserNameHistory.in_time
|
|
130
|
+
# => 返回每个用户的最新名称记录
|
|
131
|
+
|
|
132
|
+
# 在特定时间点有效的名称记录
|
|
133
|
+
UserNameHistory.in_time(Time.parse("2024-05-01"))
|
|
134
|
+
|
|
135
|
+
# 尚未开始的名称记录(为未来安排)
|
|
136
|
+
UserNameHistory.before_in_time
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## `latest_in_time` 的工作原理
|
|
140
|
+
|
|
141
|
+
`latest_in_time(:user_id)` 作用域生成一个高效的 `NOT EXISTS` 子查询:
|
|
142
|
+
|
|
143
|
+
```sql
|
|
144
|
+
SELECT * FROM user_name_histories AS h
|
|
145
|
+
WHERE h.start_at <= '2024-10-01'
|
|
146
|
+
AND NOT EXISTS (
|
|
147
|
+
SELECT 1 FROM user_name_histories AS newer
|
|
148
|
+
WHERE newer.user_id = h.user_id
|
|
149
|
+
AND newer.start_at <= '2024-10-01'
|
|
150
|
+
AND newer.start_at > h.start_at
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
这只返回在给定时间点有效的每个用户的最新记录,非常适合 `has_one` 关联。
|
|
155
|
+
|
|
156
|
+
## 提示
|
|
157
|
+
|
|
158
|
+
1. **始终将 `latest_in_time` 与 `has_one` 一起使用** - 它确保每个外键只获取一条记录。
|
|
159
|
+
|
|
160
|
+
2. **添加复合索引** 在 `[user_id, start_at]` 上以获得最佳查询性能。
|
|
161
|
+
|
|
162
|
+
3. **使用 `includes` 进行预加载** - `NOT EXISTS` 模式与 Rails 预加载高效配合。
|
|
163
|
+
|
|
164
|
+
4. **考虑添加唯一约束** 在 `[user_id, start_at]` 上以防止同一时间的重复记录。
|