你有没有遇到过这样的需求:做报表统计、任务调度或者数据清洗时,经常要算“上周最后一天”。听起来特别简单,不就是减几天的事嘛,可实际动手时,今天周几、系统时区、跨月月初这些因素混在一起,很容易把人绕晕。我去年做数据仓库的日活统计时,就因为在“上周最后一天”这个日期上算错了一天,导致一周的报表数据全对不上,排查了两个小时才发现是日期边界的问题。所以这个看似基础的问题,很值得认真聊一聊。
这篇文章我要分享的内容,适合正在写数据处理逻辑的开发、做报表的运营分析,以及维护定时任务的 DBA 或运维同学。我会从需求定义开始,给出通用算法,再用 Python、SQL、Shell 三种语言完整落地,最后把边界情况和工程化封装也一并说透。读完你不仅知道怎么写,还能理解每步计算背后的原因,以后再遇到类似日期需求就不会慌。
1. 需求拆解:先搞清楚“上周最后一天”是哪一天
1.1 三种常见的业务定义
在正式写代码前,我习惯先问自己一句:这里的“上周最后一天”,在业务上到底指哪一天?不同场景下,答案完全不同。
第一种,也是最常见的,是自然周定义:一周从周一到周日,上周最后一天就是上周日。国内大多数互联网公司的运营报表、用户行为统计,用的都是这个定义。你去看各种数据分析平台的周报,默认的“上周”范围基本都是周一至周日。
第二种,是财务口径或部分习惯使用“周日到周六”为周的地区:一周从周日开始到周六结束,那么上周最后一天是上周六。这种情况在外企、跨境电商、某些财务结算系统里很常见。
第三种,是 ISO 8601 标准,也是很多国际通用系统的默认规则:每周从周一开始,到周日结束。这与第一种本质上是一样的,都认为一周最后一天是周日,区别只在起始日和编号规则上。
所以你看,不考虑业务背景直接写代码,很可能写出来的结果跟预期差一天。我自己第一次实现时就是直接减 7 天,结果在周一跑任务时拿到的还是本周的日期,后面才意识到不能这么简单处理。
1.2 为什么不能直接“今天减7天”
很多人直觉上觉得,上周最后一天不就是当前日期减 7 天吗?但如果今天是周四,减 7 天是上周四,根本不是上周日。这个方案只有在“今天”恰好是这周最后一天的时候才成立,而真实定时任务一般每周都会跑,总会遇到非最后一天的情况。
更深层的原因在于:日期的“周”是由星期几来刻画的一组离散编号,而不是纯粹的按 7 天滚动。我们要找的不是“7 天前”,而是“距离今天最近的一个已经完整过去的周的最后一天”。要定位它,必须先知道今天是星期几,然后从今天往回推到“本周第一天”,再往前一天,才是上周最后一天。这个逻辑链路清楚了,代码怎么写都不会错。
2. 核心实现逻辑:基于“今天是星期几”的通用算法
2.1 把计算步骤拆成四条公式
以“上周最后一天 = 上周日”为例,拆解后的步骤是这样的:
- 获取今天是星期几的数值,记为
w。 - 根据周的起始日,计算今天到“本周第一天”的偏移量。
- 用
今天 - 偏移量得到本周第一天的日期。 - 用
本周第一天 - 1天得到上周最后一天。
如果采用周一作为一周的第一天(w计数为:周一=0,周二=1,……,周日=6),那么今天到本周一的偏移量就是w。本周一往前一天,就是上周日。因此最终公式为:
上周日 = 今天 - (w + 1)天举个例子:今天是周三,w = 2,那么今天 - 3天就是上周日。如果用日历验证:今天是 2025 年 4 月 16 日星期三,减 3 天是 4 月 13 日星期日,完全正确。
如果业务定义为“上周最后一天 = 上周六”(即周从周日起始,周六结束),公式变成了:
上周六 = 今天 - (w + 2)天为什么是w + 2?因为在这种情况下,本周的起点是周日,从今天到本周日的偏移量需要额外加 1 天。公式我会放在后面的工程化函数里统一处理,这里先把原理讲明白。
2.2 用 Python 写第一版函数
Python 标准库的datetime模块已经足够我们实现,不需要额外装包。第一版代码可以这么写:
from datetime import date, timedelta def get_last_sunday(today=None): """获取上周日(周一作为一周的第一天) 参数: today: 日期对象,默认为当天 返回: 上周日的日期对象 """ if today is None: today = date.today() # today.weekday() 返回 0-6,对应周一至周日 weekday = today.weekday() offset = weekday + 1 return today - timedelta(days=offset)这段代码的核心就在于offset = weekday + 1。我来解释一下这个+1:weekday是今天距离本周一的天数,而本周一减 1 天才到上周日,所以总共要多减一天。今天如果是周一,weekday = 0,那么上周日就是昨天,看起来没问题;今天如果是周日,weekday = 6,那么 offset = 7,返回 7 天前,也正是上周日,不会把今天的日期算进去。
我个人建议在实际代码里把weekday的计算步骤分开写,并加注释。因为日期逻辑容易出隐性 bug,注明每一步的含义能帮自己和后来者省很多时间。
3. 多语言实战:SQL、Shell 与其他工具怎么算
3.1 SQL 里的两种常用写法
数据库是统计需求的重灾区,多数时候我们并不想在应用层查出所有原始数据再处理,而是希望直接用 SQL 在查询里算出日期边界。以 PostgreSQL 为例,最简单的写法是利用EXTRACT(ISODOW FROM CURRENT_DATE),它返回 1 到 7,正好对应周一至周日:
-- 上周日:先回退到本周一,再往前一天 SELECT CURRENT_DATE - EXTRACT(ISODOW FROM CURRENT_DATE)::int - 1;其中CURRENT_DATE - EXTRACT(...)::int得到的就是本周一,再减 1 就是上周日。PostgreSQL 还有一个更语义化的写法:
SELECT date_trunc('week', CURRENT_DATE)::date - 1;date_trunc('week', ...)会把日期截断到本周一,然后转成日期类型再减 1 天。这个写法更直白,性能也更好。
MySQL 的WEEKDAY()函数返回的是 0 到 6,对应周一到周日,因此写法为:
SELECT DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 1 DAY);如果你用 Snowflake,可以这样:
SELECT DATEADD(day, -DAYOFWEEKISO(CURRENT_DATE) - 1, CURRENT_DATE);要注意不同数据库的“星期几”编号起点不一样,这是最容易踩坑的一步。Oracle 的TO_CHAR(date, 'D')会受会话参数影响,返回数字不固定,建议用TRUNC(date, 'IW') - 1来得到上周一或上周日,避免依赖具体语言环境。
3.2 Shell 脚本里的实用技巧
在写 cron 定时任务或 Shell 脚本时,我们经常要动态拼日期。GNUdate命令非常强大,可以直接用last sunday这种人类可读的写法:
last_sunday=$(date -d "last sunday" +%Y-%m-%d) echo "$last_sunday"但这里有个隐蔽的坑:date -d "last sunday"的行为取决于 GNU date 的版本。有些版本在今天是周日时,last sunday返回的是 7 天前而不是今天。这在大多数情况下正好是我们要的“上周日”,但仍建议通过%u参数显式计算,避免歧义。
date +%u返回 1 到 7,对应周一至周日。要得到上周日,可以直接这样算:
# 今天距离上周日的天数 = $(date +%u) last_sunday=$(date -d "-$(date +%u) days" +%Y-%m-%d)验证一下:如果今天是周四,%u为 4,date -d "-4 days"输出的是 4 天前,也就是上周日。如果今天是周一,%u为 1,减 1 天就是昨天周日。这个式子永远成立。
如果你嫌date -d在不同 Unix 系统上兼容性差,可以借助 Python 或者 Perl 来生成日期字符串,再交还给 Shell 使用。例如:
last_sunday=$(python3 -c " from datetime import date, timedelta d = date.today() print(d - timedelta(days=d.weekday() + 1)) ")3.3 不同工具方案的速查对比
为了方便你在不同环境里快速选取方案,我把常见的日期计算口径整理成了一张表:
| 环境 | 推荐写法 | 关键点 |
|---|---|---|
| Python 标准库 | today - timedelta(days=today.weekday() + 1) | weekday()周一=0,周日=6 |
| PostgreSQL | date_trunc('week', CURRENT_DATE)::date - 1 | week从周一开始 |
| MySQL | DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) + 1 DAY) | WEEKDAY()周一=0,周日=6 |
| Snowflake | DATEADD(day, -DAYOFWEEKISO(CURRENT_DATE) - 1, CURRENT_DATE) | 基于 ISO 周 |
| GNU Shell | date -d "-$(date +%u) days" +%Y-%m-%d | %u周一=1,周日=7 |
| dateutil | today + relativedelta(weekday=SU(-1)) | 需要额外安装 python-dateutil |
表格里 dateutil 的写法我单独说一下。relativedelta(weekday=SU(-1))表示最近的“上一个周日”,如果今天是周三,它返回上周日;如果今天是周日,它返回 7 天前。这个语义与“上周日”刚好一致,所以也可以使用。但如果你习惯标准库,不依赖第三方包会更省心。
4. 边界场景与隐蔽坑位:跨年、月初、时区
4.1 跨年和月初并没有想象中可怕
很多人担心,如果今天是 1 月 1 号或者 1 月 2 号,算上周日会不会算出负数然后出错?我用 Python 的timedelta做了测试:比如 2025 年 1 月 1 日是周三,weekday()是 2,减去 3 天得到 2024 年 12 月 29 日,正好是上周日,跨年完全没问题。
日期库内部实现的是日期的算术运算,它会正确退回到上个月最后几天。真正需要担心的反而是我们自己的业务逻辑里有没有写死“年份”或“月份”值。比如有人喜欢先取“上月最后一天”再去算上一周,这种间接做法容易出错,不如直接基于当天做日期加减。
4.2 时区和运行环境是隐形杀手
我遇到过一个非常典型的问题:服务器部署在某个海外机房,时区设置没统一,导致凌晨跑任务时拿到的“今天”还是 UTC 时间的前一天。比如国内已经过了周一的零点,但服务器时区还是 UTC,系统日期仍然显示周日,那么算出来的“上周日”就少退了 7 天,落到上周日当天,数据直接错乱。
解决方案是在所有涉及日期计算的模块里,统一指定时区。比如 Python 中明确使用本地时区或一个固定的业务时区:
from datetime import datetime, timezone, timedelta # 假设业务时区为 UTC+8 tz = timezone(timedelta(hours=8)) today = datetime.now(tz).date()更稳妥的做法是在配置中心或环境变量里定义“业务日期”而不是“系统日期”,由调度框架统一注入。我在团队里通常会要求所有定时任务不要直接调用date.today(),而是用任务执行时传入的批次日期,这样测试和生产环境下结果才不会漂移。
4.3 周起始日不一致导致的差异
如果两类报表用了不同的周起始日,哪怕代码逻辑完全相同,算出来的“上周最后一天”也可能差一天。举个例子,A 报表用“周一为一周第一天”,B 报表用“周日为一周第一天”。在某个周二去计算“上周最后一天”,A 报表得到的是 3 天前的周日,B 报表得到的是 4 天前的周六。两者中间隔了一个周日,很多数据分析师如果没意识到这一点,就会产生核对不一致。
所以在团队协作中,我建议把“周的起始日”定义成项目级别的全局常量,并且在查询前打印出来,或者直接体现在函数参数名里。比如函数叫get_last_day_of_last_week(week_starts_on=1)就会比get_last_sunday()清晰得多。
5. 把逻辑工程化:写一个可复用的通用函数
5.1 用参数控制不同周的起始日
成熟的代码不应该只在单一场景下正确,至少要把“周起始日”和“期望返回周几”这两个变量暴露出来。下面这份代码是我在实际项目中用过的简化版,升级了上面的第一版函数:
from datetime import date, timedelta def get_last_day_of_last_week(today=None, week_start=0, last_weekday=None): """获取上周最后一天的日期。 参数: today: 当前日期,默认为 date.today() week_start: 一周第一天在 python.weekday() 中的数值,0=周一,6=周日 last_weekday: 希望返回的星期几,默认本周最后一天(即 week_start 的前一天) 返回: 日期对象 """ if today is None: today = date.today() # 当前星期几距离 week_start 的天数 offset_to_start = (today.weekday() - week_start) % 7 this_week_start = today - timedelta(days=offset_to_start) # 如果未指定 last_weekday,最后一天是 week_start 前一天 if last_weekday is None: last_weekday = (week_start - 1) % 7 # 求 last_weekday 距离 week_start 的天数 days_ahead = (last_weekday - week_start) % 7 last_week_end = this_week_start - timedelta(days=1) # 再往后推到指定的 last_weekday # 但实际上我们只要上周最后一天,所以这里直接返回 this_week_start - 1 天即可 # 如果需要任意 weekday,再根据 days_ahead 调整 return last_week_end等等,这个函数被我写复杂了。让我重新整理一下,保持简单还容易理解。
更清晰的做法是直接定义两种策略:
from datetime import date, timedelta def last_sunday(today=None): """周一为一周之首,上周最后一天是周日""" if today is None: today = date.today() return today - timedelta(days=today.weekday() + 1) def last_saturday(today=None): """周日为一周之首,上周最后一天是周六""" if today is None: today = date.today() # 当今天为周日时,weekday()=6,需要偏移 7 天才能回到上周六 # 当今天为周一时,weekday()=0,需要偏移 2 天 # 规律是:offset = weekday() + 2 return today - timedelta(days=today.weekday() + 2)这个版本虽然函数简单,但语义非常明确,不容易出错。如果业务上只有这两种口径,优先用这种直白的方式。只有当天数口径特别多时,才引入通用参数。
5.2 用测试固定日期行为
日期逻辑最容易在“未来某天”出问题,所以我的习惯是把测试用例写全。比如用已知的固定日期来验证计算结果:
from datetime import date def test_last_sunday(): # 2025-04-16 是周三,上周日应该是 2025-04-13 assert last_sunday(date(2025, 4, 16)) == date(2025, 4, 13) # 2025-04-13 是周日,上周日应该是 2025-04-06 assert last_sunday(date(2025, 4, 13)) == date(2025, 4, 6) # 2025-01-01 是周三,跨年场景,上周日是 2024-12-29 assert last_sunday(date(2025, 1, 1)) == date(2024, 12, 29)有了这些测试,以后改代码或者升级依赖库时,只要测试一跑,就能瞬间发现日期逻辑有没有被破坏。我强烈建议把这类测试纳入 CI,因为统计系统的日期计算很少会有人手动核对,自动化测试就是安全网。
6. 实操心得:数据统计场景的复盘与排查技巧
6.1 在真实报表里怎么用最稳妥
做数据统计时,我通常不会仅仅在 SQL 里写一句日期条件就完事,而是把起始日期和结束日期都显式列出来,放在查询的第一行。比如:
WITH date_range AS ( SELECT CURRENT_DATE - EXTRACT(ISODOW FROM CURRENT_DATE)::int - 1 AS start_date, CURRENT_DATE - EXTRACT(ISODOW FROM CURRENT_DATE)::int AS end_date ) SELECT * FROM event_table, date_range WHERE event_day >= start_date AND event_day < end_date -- 注意是左闭右开,避免漏掉上周日当天 ;这里我用了“上周一作为起始日期,本周一作为结束日期”的区间方式,因为区间条件用左闭右开[,)可以避免日期时间类型里含有时分秒的边界问题。如果要统计“上周最后一天”当天,直接取start_date即可,但更多场景其实是整周区间。
6.2 排查日期问题常用的三步法
如果你发现报表里日期不对劲,先别急着改代码,按这三步排查效率最高:
第一步,确认“今天”的日期到底是多少。不要相信直觉,直接在运行环境里执行date +%F或SELECT CURRENT_DATE,排除时区影响。
第二步,确认周的起点定义。查看代码里的注释或者配置,看它认为的一周第一天是周一还是周日。可以在测试环境打印如下中间值:今天是星期几、偏移量是多少、最终算出哪一天。我之前排查问题时,就通过打印这两个输出,很快定位到是weekday()和业务定义相差一天。
第三步,用已知的固定日期做单测。比如用周一和周日的两个日期分别调用函数,观察结果是否符合预期。如果两个关键日期的输出都正确,那么其他日期也只是线性偏移,基本不会有问题。
最后分享一个我个人的小习惯:所有和“周”相关的日期函数,我都会在函数名或注释里写清业务口径。比如get_last_sunday_based_on_monday_week(),名字长一些没关系,重点是让三个月后的自己翻代码时,能一眼看明白这函数不是为了“周日的技术实现”,而是为了“周一起始的周报统计”。日期代码里最大的隐患,往往不是算法写不出来,而是算法写出了没人能确认它到底在算什么。希望这篇内容能帮你少踩几个坑。