签到,指在规定的簿册上签名或写一“到”字,表示本人已经到达。在APP中使用此功能,可以增加用户粘性和活跃度。
一个有签到功能的APP,往往会提供补签功能,连续签到多少天会给予相关的奖励;而为了进一步增加用户粘性,还会提供签到任务功能,完成任务也可获取对应的奖励。
功能用例
本文带你实现一个包含上述用例的签到功能,看完以后你会发现,签到,没有你想的那么复杂!
redis为主写入查询,mysql辅助查询。传统签到多数都是直接采用mysql为存储DB,在大数据的情况下数据库的压力较大。查询速率也会随着数据量增大而增加。所以在需求定稿以后查阅了很多签到实现方式,发现用redis做签到会有很大的优势。
本功能主要用到redis位图[1],后面我会详细讲解实现过程。
这里抛砖引玉,展示一下我们app的签到实现效果
功能大致分为两个大模块
签到流程图如下:
因为大部分功能使用redis存储,使用到mysql主要是为了存储用户总积分以及积分记录,便于查询签到记录和用户总积分
CREATE TABLE `t_user_integral` (
`id` varchar(50) NOT NULL COMMENT 'id',
`user_id` int(11) NOT NULL COMMENT '用户id',
`integral` int(16) DEFAULT '0' COMMENT '当前积分',
`integral_total` int(16) DEFAULT '0' COMMENT '累计积分',
`create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='用户积分总表'
CREATE TABLE `t_user_integral_log` (
`id` varchar(50) NOT NULL COMMENT 'id',
`user_id` int(11) NOT NULL COMMENT '用户id',
`integral_type` int(3) DEFAULT NULL COMMENT '积分类型 1.签到 2.连续签到 3.福利任务 4.每日任务 5.补签',
`integral` int(16) DEFAULT '0' COMMENT '积分',
`bak` varchar(100) DEFAULT NULL COMMENT '积分补充文案',
`operation_time` date DEFAULT NULL COMMENT '操作时间(签到和补签的具体日期)',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='用户积分流水表'
//人员签到位图key,一个位图存一个用户一年的签到状态,以userSign为标识,后面的两个参数是今年的年份和用户的id
public final static String USER_SIGN_IN = "userSign:%d:%d";
//人员补签key,一个Hash列表存用户一个月的补签状态,以userSign:retroactive为标识,后面的两个参数是当月的月份和用户的id
public final static String USER_RETROACTIVE_SIGN_IN = "userSign:retroactive:%d:%d";
//人员签到总天数key,以userSign:count为标识,后面的参数是用户的id
public final static String USER_SIGN_IN_COUNT = "userSign:count:%d";
接口restful的形式,头信息里传入用户id
@ApiOperation("用户签到")
@PostMapping("/signIn")
@LoginValidate
public ResponseResult saveSignIn(@RequestHeader Integer userId) {
return userIntegralLogService.saveSignIn(userId);
}
sevice实现层
public ResponseResult saveSignIn(Integer userId) {
//这里是我们的公司统一返回类
ResponseResult responseResult = ResponseResult.newSingleData();
//用String.format拼装好单个用户的位图key
String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, LocalDate.now().getYear(), userId);
//位图的偏移点为当天的日期,如今天,偏移值就是1010
long monthAndDay = Long.parseLong(LocalDate.now().format(DateTimeFormatter.ofPattern("MMdd")));
responseResult.setMessage("今日已签到");
responseResult.setCode((byte) -1);
//检测是否用户今日签到过,用getBit可以取出该用户具体日期的签到状态(位图的值只有两个,1或者0,这里1代表true)
if (!cacheClient.getBit(signKey, monthAndDay)) {
//位图的set方法会返回该位图未改变前的数值,这里如果之前没有签到过默认是0,也就是false
boolean oldResult = cacheClient.setbit(signKey, monthAndDay);
if (!oldResult) {
//计算出这个月该用户的到今天的连续签到天数,此方法参照下方计算连续签到天数的代码块
int signContinuousCount = getContinuousSignCount(userId);
//此方法参照下方记录签到积分类型以及连续签到积分代码块
doSaveUserIntegral(userId, signContinuousCount);
responseResult.setCode((byte) 0);
}
}
return responseResult;
}
计算连续签到天数
/**
* @description: 获取连续签到天数
* @author: chenyunxuan
* @updateTime: 2020/8/25 4:43 下午
*/
private int getContinuousSignCount(Integer userId) {
int signCount = 0;
LocalDate date = LocalDate.now();
String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, date.getYear(), userId);
//这里取出的是位图一个偏移值区间的值,区间起始值为当月的第一天,范围值为当月的总天数(参考命令bitfield)
List<Long> list = cacheClient.getBit(signKey, date.getMonthValue() * 100 + 1, date.getDayOfMonth());
if (list != null && list.size() > 0) {
//可能该用户这个月就没有签到过,需要判断一下,如果是空就给一个默认值0
long v = list.get(0) == null ? 0 : list.get(0);
for (int i = 0; i < date.getDayOfMonth(); i++) {
//如果是连续签到得到的long值右移一位再左移一位后与原始值不相等,连续天数加一
if (v >> 1 << 1 == v) return signCount;
signCount += 1;
v >>= 1;
}
}
return signCount;
}
记录签到积分类型以及连续签到积分
public Boolean doSaveUserIntegral(int userId, int signContinuousCount) {
int count = 0;
//叠加签到次数
cacheClient.incrValue(String.format(RedisKeyConstant.USER_SIGN_IN_COUNT, userId));
List<UserIntegralLog> userIntegralLogList = new LinkedList<>();
userIntegralLogList.add(UserIntegralLog.builder()
.createTime(LocalDateTime.now())
.operationTime(LocalDate.now())
.bak(BusinessConstant.Integral.NORMAL_SIGN_COPY)
.integral(BusinessConstant.Integral.SIGN_TYPE_NORMAL_INTEGRAL)
.integralType(BusinessConstant.Integral.SIGN_TYPE_NORMAL)
.userId(userId)
.build());
count += BusinessConstant.Integral.SIGN_TYPE_NORMAL_INTEGRAL;
//连续签到处理,获取缓存配置连续签到奖励
//因为每个月的天数都不是固定的,连续签到奖励是用的redis hash写入的.所以这个地方用32代替一个月的连续签到天数,具体配置在下方图中
if (signContinuousCount == LocalDate.now().lengthOfMonth()) {
signContinuousCount = 32;
}
Map<String, String> configurationHashMap = cacheClient.hgetAll("userSign:configuration");
String configuration = configurationHashMap.get(signContinuousCount);
if (null != configuration) {
int giveIntegral = 0;
JSONObject item = JSONObject.parseObject(configuration);
giveIntegral = item.getInteger("integral");
if (giveIntegral != 0) {
if (signContinuousCount == 32) {
signContinuousCount = LocalDate.now().lengthOfMonth();
}
userIntegralLogList.add(UserIntegralLog.builder()
.createTime(LocalDateTime.now())
.bak(String.format(BusinessConstant.Integral.CONTINUOUS_SIGN_COPY, signContinuousCount))
.integral(giveIntegral)
.integralType(BusinessConstant.Integral.SIGN_TYPE_CONTINUOUS)
.userId(userId)
.build());
count += giveIntegral;
}
}
//改变总积分和批量写入积分记录
return updateUserIntegralCount(userId, count) && userIntegralLogService.saveBatch(userIntegralLogList);
}
连续签到获取的积分配置以及文案配置
补签功能是一个签到补充功能,主要就是方便用户在忘了签到的情况下也能通过补签功能达到相应的连续签到条件,从而得到奖励.
补签主方法
//day表示需要补签的日期,因为我们平台的签到周期是一个月所以只需要传日的信息就可以,入 7号传入7
public ResponseResult saveSignInRetroactive(Integer userId, Integer day) {
Boolean result = Boolean.TRUE;
ResponseResult responseResult = ResponseResult.newSingleData();
responseResult.setMessage("今日无需补签哟");
responseResult.setCode((byte) -1);
LocalDate timeNow = LocalDate.now();
//检测是否补签达上限
String retroactiveKey = String.format(RedisKeyConstant.USER_RETROACTIVE_SIGN_IN, timeNow.getMonthValue(), userId);
//从redis中取出用户的当月补签的集合set.我们平台的限制是三次补签
Set<String> keys = cacheClient.hkeys(retroactiveKey);
if (CollUtil.isNotEmpty(keys) && keys.size() == 3) {
responseResult.setMessage("本月补签次数已达上限");
result = Boolean.FALSE;
}
//检查补签积分是否足够,这里就是一个简单的单表查询,用于查询积分是否足够本次消耗
UserIntegral userIntegral = userIntegralService.getOne(new LambdaQueryWrapper<UserIntegral>().eq(UserIntegral::getUserId, userId));
//这里只是简单的做了一个map放置三次补签分别消耗的积分(key:次数 value:消耗积分),也可参照之前连续签到配置放入redis缓存中便于后台管理系统可配置
Integer reduceIntegral = getReduceIntegral().get(keys.size() + 1);
if (reduceIntegral > userIntegral.getIntegral()) {
responseResult.setMessage("您的橙汁值不足");
result = Boolean.FALSE;
}
if (result) {
LocalDate retroactiveDate = LocalDate.of(timeNow.getYear(), timeNow.getMonthValue(), day);
String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, timeNow.getYear(), userId);
long monthAndDay = Long.parseLong(retroactiveDate.format(DateTimeFormatter.ofPattern("MMdd")));
//后端检测是否用户今日签到过同时补签日期不可大于今天的日期
if (!cacheClient.getBit(signKey, monthAndDay) && timeNow.getDayOfMonth() > day) {
boolean oldResult = cacheClient.setbit(signKey, monthAndDay);
if (!oldResult) {
//补签记录(:月份) 过月清零,过期时间是计算出当前时间的差值,补签次数是一个月一刷新的
cacheClient.hset(retroactiveKey, retroactiveDate.getDayOfMonth() + "", "1",
(Math.max(retroactiveDate.lengthOfMonth() - timeNow.getDayOfMonth(), 1)) * 60 * 60 * 24);
//这里就是对积分总表减少.以及对积分记录进行记录.参照下方代码块
doRemoveUserIntegral(userId, reduceIntegral, RETROACTIVE_SIGN_COPY);
responseResult.setCode((byte) 0);
responseResult.setMessage("补签成功");
}
}
}
return responseResult;
}
积分减少并写入积分变动记录
public Boolean doRemoveUserIntegral(int userId, int reduceIntegral, String bak) {
return updateUserIntegralCount(userId, -reduceIntegral)
&& userIntegralLogService.save(UserIntegralLog.builder()
.createTime(LocalDateTime.now())
.operationTime(LocalDate.now())
.bak(bak)
.integral(-reduceIntegral)
.integralType(BusinessConstant.Integral.RETROACTIVE_SIGN_COPY.equals(bak) ?
BusinessConstant.Integral.SIGN_TYPE_RETROACTIVE : BusinessConstant.Integral.SIGN_TYPE_WELFARE)
.userId(userId)
.build());
}
至此,用例中的签到与补签功能实现完成,接下来我们看看签到日历以及签到任务如何实现。
签到周期: 常用的签到周期为一周或者一个月。我们的app采用的是一个月的方案(市面上的签到日历界面都大同小异,接下来我会给大家分享以月为周期的签到日历实现方案以及伴生的签到任务实现方案)
通过图上分析,可大致把这个界面分成四个部分
通过分析我把这个界面分成了三个接口
/signIn
GET协议 用于查询头部的总积分和签到日历部分./signIn/configuration
GET协议 查询连续签到文案配置,如果不需要后台可配置连续签到获取积分的数量和文案,此接口可省略,前端写死./signIn/task
GET协议 用于查询签到任务,以及各个任务的完成状态.public ResponseResult selectSignIn(Integer userId, Integer year, Integer month) {
boolean signFlag = Boolean.FALSE;
String signKey = String.format(RedisKeyConstant.USER_SIGN_IN, year, userId);
LocalDate date = LocalDate.of(year, month, 1);
//这个方法前面的文章有介绍过.是查询出一个偏移值区间的位图集合
List<Long> list = cacheClient.getBit(signKey, month * 100 + 1, date.lengthOfMonth());
//查询reids中当前用户补签的hash列表 (hash列表的key为补签的日期,value存在就说明这个日期补签了)
String retroactiveKey = String.format(RedisKeyConstant.USER_RETROACTIVE_SIGN_IN, date.getMonthValue(), userId);
Set<String> keys = cacheClient.hkeys(retroactiveKey);
TreeMap<Integer, Integer> signMap = new TreeMap<>();
if (list != null && list.size() > 0) {
// 由低位到高位,为0表示未签,为1表示已签
long v = list.get(0) == null ? 0 : list.get(0);
//循环次数为当月的天数
for (int i = date.lengthOfMonth(); i > 0; i--) {
LocalDate d = date.withDayOfMonth(i);
int type = 0;
if (v >> 1 << 1 != v) {
//状态为正常签到
type = 1;
//这里和当前日期对比,方便前端特殊标记今天是否签到
if (d.compareTo(LocalDate.now()) == 0) {
signFlag = Boolean.TRUE;
}
}
if (keys.contains(d.getDayOfMonth() + "")) {
//状态为补签
type = 2;
}
//返回给前端当月的所有日期,以及签,补签或者未签的状态
signMap.put(Integer.parseInt(d.format(DateTimeFormatter.ofPattern("dd"))), type);
v >>= 1;
}
}
ResponseResult responseResult = ResponseResult.newSingleData();
Map<String, Object> result = new HashMap<>(2);
//前文有介绍过这个表存储了用户的总积分
UserIntegral userIntegral = userIntegralService.getOne(new LambdaQueryWrapper<UserIntegral>().eq(UserIntegral::getUserId, userId));
//用户总积分
result.put("total", userIntegral.getIntegral());
//用户今日是否签到
result.put("todaySignFlag", signFlag ? 1 : 0);
//后端返回日期是为了防止手机端直接修改系统时间导致的问题
result.put("today", LocalDate.now().getDayOfMonth());
//当月的签到情况
result.put("signCalendar", signMap);
//返回给前端这个月的第一天是星期几,方便前端渲染日历图的时候定位
result.put("firstDayOfWeek", date.getDayOfWeek().getValue());
//服务器的当前月份(同上,防止手机端直接修改系统时间)
result.put("monthValue", date.getMonthValue());
//用户当月补签的次数
result.put("retroactiveCount", keys.size());
//日历部分会有上月的结尾几天的数据,所以这里需要返回给前端上个月共有多少天
result.put("lengthOfLastMonth", date.minusMonths(1).lengthOfMonth());
responseResult.setData(result);
return responseResult;
}
因为整体使用了Redis位图的查询,每个用户的签到数据都是通过key隔离开的,时间复杂度为 O(1)
.实测百毫秒内可返回数据
这一部分采用的是redis和mysql结合查询的方式.任务我们做了后台可配置.分为只能完成一次的 福利任务
和每天都可以重置的 每日任务
.
设计这张任务表的时候,总要就是类型和跳转方式需要注意.因为不同的任务有不同的功能划分.用 jump_type
去区分各自的功能区域.jump_source
可以是H5地址也可以是手机端的路由地址.可以做到灵活调控.前端调用完成任务的接口传入任务对应的 task_tag
就可以完成指定的任务
CREATE TABLE `t_user_integral_task` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'id',
`task_type` tinyint(4) DEFAULT '1' COMMENT '任务类型 1.每日任务 2福利任务',
`task_tag` varchar(100) DEFAULT NULL COMMENT '任务前端标识(大写字母组合)',
`task_title` varchar(100) DEFAULT NULL COMMENT '任务标题',
`icon` varchar(255) DEFAULT NULL COMMENT '小图标',
`task_copy` varchar(100) DEFAULT NULL COMMENT '任务文案',
`integral` int(16) DEFAULT '0' COMMENT '任务赠送积分数',
`jump_type` tinyint(4) DEFAULT NULL COMMENT '跳转方式 1.跳转指定商品 2.跳转链接 3.跳转指定接口,4:跳转随机商品',
`jump_source` text COMMENT '跳转或分享的地址',
`sort` tinyint(2) DEFAULT '0' COMMENT '排序号',
`delete_flag` tinyint(2) DEFAULT '0' COMMENT '删除/隐藏,0:未删除/未隐藏,1:已删除/已隐藏',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='用户任务表'
因为每日任务和福利任务大概也就十条左右,所以mysql查询是非常快速的.然后完成状态储存在redis中,时间复杂度为 O(1)
public ResponseResult selectSignInTask(Integer userId) {
ResponseResult responseResult = ResponseResult.newSingleData();
//先查出签到任务的mysql记录.
List<UserIntegralTask> userIntegralTaskList = list(new LambdaQueryWrapper<UserIntegralTask>()
.orderByDesc(UserIntegralTask::getTaskType).orderByAsc(UserIntegralTask::getSort));
//创建一个map,key为任务的task_tag,value存在则是完成了该任务.
//每日任务和福利任务分为两个reids hash存储.每日任务的key中包含当天日期,过期时间为一天.福利任务则是永久保存
Map<String, String> completeFlagMap = new HashMap<>(userIntegralTaskList.size());
Map<String, String> welfareMap = cacheClient.hgetAll(String.format(RedisKeyConstant.USER_SIGN_WELFARE_TASK, userId));
if (CollUtil.isNotEmpty(welfareMap)) completeFlagMap.putAll(welfareMap);
Map<String, String> dailyMap = cacheClient.hgetAll(String.format(RedisKeyConstant.USER_SIGN_DAILY_TASK, LocalDate.now().getDayOfMonth(), userId));
//把两个hash合并
if (CollUtil.isNotEmpty(dailyMap)) completeFlagMap.putAll(dailyMap);
//循环库中的任务列表,并用hash的get方法查询是否完成,然后给到前端
userIntegralTaskList.forEach(task -> {
task.setCreateTime(null);
task.setUpdateTime(null);
task.setIntegral(null);
String value = completeFlagMap.get(task.getTaskTag());
if (null == value) {
task.setCompleteFlag(0);
} else {
task.setCompleteFlag(1);
}
});
responseResult.setData(userIntegralTaskList);
return responseResult;
}
完成任务的方法.设定为一个公共方法.传入对应的 task_tag
标识去完成指定任务.也就只需要判断一下他是每日任务还是福利任务.分别写入不同的redis hash里.
//伪代码
public ResponseResult saveSignInTask(Integer userId, String tag) {
//查询出mysql中对应的tag任务,获取关键信息.(`integral`)
....
//写入积分记录表.对应当前任务title的记录
...
//在redis里写入当前用户的这个任务完成状态(这里要注意如果是每日任务要给hash 列表给一天的过期时间,防止脏数据长时间不被清理,占用redis的内存空间)
}
< END >
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/C8Oq8cwpXdj3YqGX2e5o1Q
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。