审核平台前端新老仓库迁移

背景

审核平台接入50+业务,提供在线审核及离线质检、新人培训等核心能力,同时提供数据报表、资源追踪、知识库等工具。随着平台的飞速发展,越来越多的新业务正在或即将接入审核平台,日均页面浏览量为百万级别。如今审核平台已是公司内容生产链路上的关键一环,是保障内容安全的重要防线,因此稳定性至关重要。

过去一年我们曾对前端项目进行框架升级,考虑风险与成本最小化,选择了渐进式升级,利用微前端实现Vue2和Vue3共存,新接业务在Vue3仓库中开发。经过一年的迭代,Vue3项目趋于稳定,沉淀了大部分通用能力。为了降低多仓库维护心智,同时解决核心模块的技术债务,考虑将剩余活跃代码进行重构并迁移至新仓库。

面向迁移的重构 - 整洁架构在前端的应用

案例选择

参考前端埋点报表,选择老仓库中页面维度访问量最高的路由,对线上使用情况进行摸排。日常业务现状是点直融合,直播业务配置化接入需求较多,因为业务形态的差异,定制需求多,现有配置能力无法满足,需扩充。开发现状是通用配置化代码改动频繁,逻辑复杂,开发门槛较高,影响范围大,牵一发而动全身。因此选择配置化详情页作为优先重构并迁移的对象。

配置化详情页采用的是业务定制化的低代码方案,包含schema渲染器和任务流两部分。当前已沉淀近百份json schema,托管在内部其他低代码平台上。页面覆盖40多个业务,占据平台约20%访问量和35%独立访客。

图片

图片

如果将页面看做一个黑盒子,依据唯一标识(路由path和query等)从node服务、平台服务以及外部业务方服务获取数据,基于页面内部规则渲染页面。审核员浏览并进行通过、驳回等操作,提交后将对视频、弹幕等业务资源产生影响。

图片

schema渲染器基于json schema和接口数据,在平台内生成路由信息与页面内容,负责各种模式的页面分发、物料分发,并提供敏感词、快照、洗数等通用平台能力。

代码现状是数据获取、提交操作和页面复杂逻辑分散在vue文件和store中,业务逻辑和UI框架耦合严重,不利于集成自动化测试和框架升级。待办、任务、资源等边界划分不清晰,平铺在“巨石store“中,维护成本极高且代码改动风险大。渲染器和任务流逻辑不够内聚,耦合严重,无法做到关注点分离。因此需要寻找一种合适的架构进行重构,减弱业务逻辑对UI框架的依赖,增强可测试性。

整洁架构

整洁架构由Robert C. Martin在2012年提出,核心思想是将软件系统拆分为独立的层次,以实现高内聚、低耦合、可测试和可维护。

图片

一共分为四个层级,环与环之间,存在一个依赖关系原则:源代码中的依赖关系,必须只指向同心圆的内层,即由低层机制指向高级策略。

  • 实体层:包含业务领域的核心概念和业务逻辑。

  • 用例层:实现特定的业务用例,将实体层的业务逻辑与具体的应用场景结合起来。

  • 接口适配器层:负责处理与外部系统的交互比如用户界面、数据库、web服务等。将外部系统的请求和数据格式转化为用例层和实体层能够理解和处理的对象。

  • 框架和驱动层:包含具体的框架和工具,比如web框架、数据库驱动等。

优点是可以在没有UI、数据库、web服务器或其他外部基础设施的情况下测试业务逻辑;降低对UI框架的依赖,比如跨端开发时,业务逻辑可以复用,只需要做UI层的适配。相应的,缺点也很明显,过于复杂,数据需要经过多层处理。学习成本较高,容易过度设计,增加复杂性,灵活性较低。适用于大型复杂项目,对于需要长期维护和持续开发的项目,清晰层次结构和明确依赖关系有助于减少代码腐化,更容易适应需求变化。针对我们选择的模块,审核前端配置化页面,比较适合。

重构

图片

图片

实体层

图片

主要可拆分成待办、任务、资源等实体。待办实体,主要提供待办的基础信息、获取配置等属性和方法。任务实体提供任务的状态、任务耗时、调度配置、任务数据,计时和拉取任务流程等。资源实体提供资源的详情数据、获取详情及数据清洗方法等。待办实体包含了配置详情页所需的核心数据,任务实体高度抽象了核心任务流。在新业务接入过程中,实体层一般不变动,通过依赖倒置划分架构边界。

// entities/todo.js
export default class Todo {
    todoId
    businessId
    todoConfig
    ...
    constructor() {}
    async getTodoConfig() {
        // 获取配置
    }
}

// entities/task.js
export default class Task {
  dispatch_conf
  listData
  timeCount
  ...
  constructor() {}
  async getTaskDetail({ getTask, taskFormat, afterGetTask}) {
    // 抽象封装核心任务流程
  }
  // 计时逻辑
  startTimer() {}
  clearTimers() {}
}

// entities/resource.js
export default class Resource {
    detail
    dataReady
    constructor() {}
    async getResourceDetail({ getResource, resourceFormat, afterGetResource }) {
        // 抽象封装资源模式核心流程
    }
}
 

用例层

用例层针对洗数、提交等复杂场景,通过调用实体层来实现特定的业务逻辑,是适配器层与实体层的中介。例如封装了基于配置的洗数中间件,列表模式的单个和批量提交,卡片模式的单个和批量提交,快照上报、自动化质检的复杂逻辑。用例层包含了系统的复杂业务逻辑,为单元测试提供了便利,可以独立于UI和外部系统进行测试。

// usecase/use-single-submit
import { get } from 'lodash-es' // 三方工具库
import { ANNOTATION_SINGLE_OPER_PASS_NAME } from '@/constants' // 常量
import { workbenchApi } from '@/api'
import { setLogData } from '@/utils/xx' // 工具函数
export function getSingleSubmitParams({ data, state.xxx }) {
  //... 逻辑处理
  return params
}
export function submitAuditSingle({ data, afterTaskSubmit }) {
  const params = ...
  workbenchApi.submit(params).then((res) => {
    if(res.code = xxx){
      afterTaskSubmit() // 调用钩子函数
    }
  })
}

适配器层

适配器层包含store和UI,调用用例层的代码,主要负责将依赖UI、外部服务、设备等的数据处理为用例层可以使用的“干净数据”。适配器层一般不包括复杂的业务逻辑,因此在框架迁移时仅需关注基本的框架差异,适合自动化代码转换。

// store/todoConfigDetail
import { getTodoInfo } from '@/struct/TodoConfigDetailStruct/usecase/use-todo'
import { getTaskInfo, getTask, taskDispatchListFormat } from '@/struct/TodoConfigDetailStruct/usecase/use-task'
import { getSingleSubmitParams, submitAuditSingle } from '@/struct/TodoConfigDetailStruct/usecase/use-single-submit'
const todo = ref({})
async function init({ $route }) {
  const query = $route.query
  const todoId = +query.todo_id
  ...
  set(todo, getTodoInfo({ todoId, ... }))
  await get(todo).getTodoConfig()
  ...
}
function getTaskDetail() {
  get(task).getTaskDetail({
    getTask: async ({ noSeize, drillTaskIds }) => await getTask({ todo: get(todo), noSeize, drillTaskIds }),
    taskFormat: async (data) => await taskDispatchListFormat({ data, schema: get(todo).schema })
    afterGetTask: (res) => { ... }
  })
}
async function submit(data) {
  if (single) {
    const params = getSingleSubmitParams({ data, todo: get(todo) })
    submitAuditSingle({ params, afterTaskSubmit: () => { ... } })
  } else {
    ...
  }
}
// Audit.vue
const todoConfigDetailStore = useTodoConfigDetailStore()
const { todo, task, multipleSelection } = storeToRefs(todoConfigDetailStore)
const { getTaskDetail, submit } = todoConfigDetailStore

新业务接入一般对实体层和用例层无改动,仅需适配器层增加相应的展示物料,尽可能避免“牵一发动全身”。新架构会有一定的初学成本,但结合审核平台复杂的项目现状和持续接入新业务的节奏,长远来看对于系统稳定性、可测试性有一定帮助,同时降低UI框架依赖性。

基于新的架构,可分层进行自动化测试和自动代码转换。

完善用例层的单元测试

用例层为纯函数,不依赖框架、设备、三方服务等。单元测试的技术栈为jest和vue-test-utils,从审核员的基本工作模式入手,针对任务领取、数据清洗与展示、稿件处理三个环节完善测试用例。因为是新老仓库迁移,所以可以将线上环境视为基准进行用例采集。根据业务重要性、线上访问情况,按优先级执行测试。单测能有效降低回归成本,在新业务持续接入的背景下,保障系统稳定性。

适配器层的自动代码转换 - 基于gogocode将vue2升级为vue3 setup语法

调研与分析

在寻求升级方案的过程中,我们对比了两款工具:Gogocode 和 vue2-to-composition-api。以下是它们的简要对比:

功能

Gogocode

vue2-to-composition-api

缺点默认不支持转换成 Vue3 setup 语法不支持 template 转换
转换规则覆盖转换规则列表转换效果
特性像 jQuery 一样修改AST将 Vue2 代码转换为 Vue 3 的 Composition API 格式
2. jQuery-like API 简化了 AST 修改成本2. 支持在线转换
优点1. 支持自定义插件1. 支持转换成 Vue3 setup 语法

经过调研,gogocode 是基于 AST 封装的库可扩展空间大,但是默认 gogocode-plugin-vue 不支持转换成 Composition API

vue2-to-composition-api 倒是支持 Composition API,但是不支持 template 部分的转换。

考虑到我们的项目中有许多自定义的转换逻辑,如 UI 库替换、store 替换等,我们最终决定使用 gogocode 作为主要工具,并结合其他手段来实现 vue2 到 vue3 的全面升级。

实施与探索

基础:升级语法

升级语法是借助 gogocode 的 replace 方法实现的,通过 $$$ 匹配符保留所需要的代码块,将 Vue2 的语法快速替换成 Vue3 的语法。

// 替换datascriptAst.replace("data() {return {$$$};}", `const $data = reactive({$$$})`);
// 替换propsscriptAst.replace("props:{$$$}", "const props = defineProps({$$$})");​​​​​​​
// 替换生命周期scriptAst.replace("created(){$$$}", "onBeforeMount(()=>{$$$})")  .replace("mounted(){$$$}", "onMounted(()=>{$$$})")  .replace("async mounted(){$$$}", "onMounted(async ()=>{$$$})")  .replace("beforeUnmount(){$$$}", "onBeforeUnmount(()=>{$$$})")  .replace("unmounted(){$$$}", "onUnmounted(()=>{$$$})")  .replace("beforeDestroy(){$$$}", "onBeforeUnmount(()=>{$$$})")  .replace("destoryed(){$$$}", "onUnmounted(()=>{$$$})");

效果如下,通过 replace 替换的方式适合大部分场景,比如 methods、filters、watch 等

图片

进阶:处理模板中的变量、函数中的this变量

template绑定的变量可能是data,可能是props,还可能是 methods

想要替换 template 中的变量,需要先收集 data、props、methods 中的 keys

​​​​​​​

getDataKeys() {  const keys = new Set();  // 只需要第一层的key,所以deep设为1  this.scriptAst.find('data() {$$$}').find('$_$:$_$', { deep: 1 }).each(node => {    if (node.match[0] && node.match[0][0].node.type === 'Identifier') {      keys.add(node.match[0][0].value);    }  });  return Array.from(keys)}getPropsKeys() {  const keys = new Set();  this.scriptAst.find('props: {$$$1}', { deep: 1 }).each((node) => {    if (node.match['$$$1']) {      node.match['$$$1'].forEach((item) => {        if (item.key && item.key.type === 'Identifier') {          keys.add(item.key.name)        }      })    }  });
  return Array.from(keys)}// methods 有点小复杂,需要考虑异步函数,普通函数和键值对的写法getMethodsKeys() {  const methodsAst = this.scriptAst.find('methods:{$$$}');  const methods = methodsAst.find('$_$() {$$$1}');  const asyncmMethods = methodsAst.find('async $_$(){$$$1}');  const mapKeys = methodsAst.find('$_$:$$$1', { deep: 1 });  const methodNames = [];  methods.each(node => {    if (node.match[0] && node.match[0][0]) {      methodNames.push(node.match[0][0].value);    }  });  asyncmMethods.each(node => {    if (node.match[0] && node.match[0][0]) {      methodNames.push(node.match[0][0].value);    }  });  mapKeys.each(node => {    if (node.match[0] && node.match[0][0]) {      methodNames.push(node.match[0][0].value);    }  });  return methodNames;}

收集完成后可以开始遍历 template 中的 attr,并替换所绑定的变量了。

​​​​​​​

handlTemplate() {    // 替换attr, 例如 <div :value="value"></div>    this.ast.find("<template></template>").find(`<$_$ ="$$$0" >$$$1</$_$>`).each((node) => {      node.match['$$$0'].forEach(attr => {        if (attr && attr.value) {          this.dataKeys.some(keyName => {            const reg = new RegExp(`${keyName}\\b`, 'g')            const macth = reg.test(attr.value.content);            attr.value.content = attr.value.content.replace(reg, `$data.${keyName}`)            if (macth) {              return true;            }          })          this.methodsKeys.some(keyName => {            const reg = new RegExp(`\\b${keyName}\\b`, 'g')            const macth = reg.test(attr.value.content);            attr.value.content = attr.value.content.replace(reg, `methods.${keyName}`)            if (macth) {              return true;            }          })
          this.propsKeys.some(keyName => {            const reg = new RegExp(`\\b${keyName}\\b`, 'g')            const macth = reg.test(attr.value.content);            attr.value.content = attr.value.content.replace(reg, `props.${keyName}`)            if (macth) {              return true;            }          })        }      })      // 替换content,例如:<div>{{value}}<div>      node.match['$$$1'].forEach(node => {        if (node.content && node.content.value) {          // 省略:与上面类似        }      })    })  }

说到 this 替换也是一个繁琐的问题,this.xx, xx 可以是 data,可以是props,可以是 function,还可以是私有属性等。

所以我们需要先把组件中的 data、props、methods、mapGetter 中的 keys 都收集一遍,然后再替换 script 中的 this 变量。​​​​​​​

// 正则替换更方便,所以需要放在最后一步替换handlThis(code) {  code = code.replace(/this\.([_$0-9a-zA-Z]+)/g, (match, $1) => {    // 替换function body 中的 data引用    if (this.dataKeys.includes($1)) {      return `$data.${$1}`;    }    // 替换function body 中的 methods调用    else if (this.methodsKeys.includes($1)) {      return `methods.${$1}`;    }    // 替换 vm 私有属性    else if ($1 && $1[0] === '$') {      return `$vm.${$1}`;    } else if (this.computedKeys.includes($1)) {      return $1    } else if (this.propsKeys.includes($1)) {      return `props.${$1}`    }    return `$vm.${$1}`  })  // 替换function body 中的 动态methods调用  code = code.replace(/this\[(.+)\]/g, (match, $1) => {    return `methods[${$1}]`;  })  return code}

进阶:动态调用this.xxx该如何解决

​​​​​​​

async parseOpenDialog(payload) {  const { schema, data } = payload  await this[schema.method](    parseSchema,    data,    dialogParams,    dialogType  )}

按 vue3 新的写法,一般是展开的​​​​​​​

const parseOpenDialog = async (payload) => {}

但是按这个习惯来转换,就无法做到动态调用this.xxx,我们可以尝试把方法都放在methods对象中,有点类似 vue2​​​​​​​

const methods = {  parseOpenDialog: async (payload) => {  },  xxx: async() => {  }}

在替换 this 时,将 this 替换成 methods 变成 methods[schema.method]()。

其他技巧

1 . 原来 vue2 中肯很多属性挂在组件实例上,比如 $route, $router, $emit 甚至自定义的属性等等。下面是 vue3 中获取组件实例的方法。​​​​​​​

import { getCurrentInstance } from 'vue'
const { proxy: $vm } = getCurrentInstance()

$vm.xxx = '自定义属性'
 

2 . 原来使用的 vuex,现在使用的是 pinia。我们需要先收集 ...mapState($_$, [$$$1]),然后使用 storeToRefs 代替​​​​​​​

// 获取store 使用storeToRefsif (this.storeType === 'pinia') {  this.scriptAst  .find("computed:{}")  .before(`const {${stateNames.join(',')}} = storeToRefs(${this.getPiniaStoreName(key)})`)  .replace("computed:{$$$}", "$$$")}

最终将上述转换能力封装成一个库,通过 npm 安装来实现组件批量升级。

迁移前后的E2E测试 - 视觉辅助UI自动化测试

端到端测试是确保新旧系统平稳过渡的关键步骤。本次迁移依旧遵循渐进式升级的原则,新增v3路由,线上新老路由共存。共分为功能测试、UI测试、性能测试三个部分。功能测试为单元测试的补充,主要验证新老路由下核心操作路径及提交参数的一致性,拦截请求避免对线上造成影响。性能由通用监控大盘进行保障。UI对比测试是本次的重点。

新老仓库分别基于Element UI和Element Plus,Element Plus重新设计了组件以适应Vue3,组件尺寸体系调整为更自然的大中小选项。间距优化为更通用的4px体系,主要涉及 padding 和 margin 属性修改、 font-size 等字体和图标大小修改等。因此,虽然大部分组件在外观上保持相似,视觉和布局上可能有一些差异。由于业务组件具备一定复杂性,手写测试用例工作量繁琐,新旧页面组件可能存在差异无法完全复用,方案也不具备通用性。因此考虑使用计算机视觉技术来识别和验证用户界面的元素。

基于公司内部的自动化测试平台,测试框架为Playwright,测试语言选择Python以更好的利用丰富的图像处理库。指定CSS选择器,随机选择页面上的元素进行截图和对比,设定阈值进行判断。图像相似度对比可分为传统的基于像素差的方法和基于图像特征的方法。图像特征有SIFT、ORB等特征提取方法和深度学习方法。分别选择一种代表性的算法进行对比和测试。

  • SSIM(结构相似性指数),同时考虑图片亮度、对比度与结构信息。用于检测两张相同尺寸的图像的相似性。对于图像的亮度和对比度变化具有较好的鲁棒性。

  • SIFT(尺度不变特征变换),用于在图像中检测和描述局部特征,这些特征对图像的缩放、旋转和部分亮度变化具有不变性。能够检测和匹配不同尺寸下的特征,对图像的仿射变换、噪声和部分遮挡具有较好的鲁棒性。

  • LPIPS是一种深度学习特征,用于量化图像之间的感知相似性,通过比较图像在深度神经网络中的特征表示来工作,这些特征能够捕捉到人类视觉所关注的图像细节。基于一个大规模的人类感知相似性判断数据集,包含484K个人类的判断,涵盖了多种图像变换和失真类型。使用深度特征(例如VGG网络中的特征)来衡量图像相似性,比传统的度量方法更有效。

v2v3SSIMSIFTLPIPS

图片

图片

0.6560.8550.909

图片

图片

0.9160.8740.961

图片

图片

0.6580.8570.881

图片

图片

0.8770.8550.926

图片

图片

0.5510.8360.947

图片

图片

0.9290.8840.942

实验表明,基于深度学习特征的相似度对比结果更接近用户感知,针对Vue2升级Vue3 UI组件库导致的间距、字体、尺寸等细微差异判断更准确。因此采用LPIPS作为对比算法。​​​​​​​

def compare_images(url1, url2):  loss_fn = lpips.LPIPS(net = 'alex')  img1 = cv2.imread(url1)  img2 = cv2.imread(url2)  if img1 is not None and img2 is not None and img1.size > 0 and img2.size > 0:      img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))      cv2.imwrite(url2, img2)      combined_image = cv2.hconcat([img1, img2])      ex_img1 = lpips.im2tensor(lpips.load_image(url1))      ex_img2 = lpips.im2tensor(lpips.load_image(url2))      d = loss_fn.forward(ex_img1, ex_img2)      if d is not None:          cv2.putText(combined_image,'score: %.3f'%(1 - d.mean()), (20, 20), cv2.FONT_ITALIC, 0.4, (255, 0, 255))      return d, combined_image  else:      return None

依据业务流程,分别访问新老路由,对页面指定元素进行截图、对比和拼接,输出测试截图和完整的测试报告。

图片

图片

采用视觉辅助UI自动化测试,更接近用户真实感知,大大降低测试用例复杂度,提升测试效率,无需关注繁杂的DOM元素层级。

上线

以页面配置的形式按待办进行灰度,跳转至新路由。单元测试已集成至项目流水线中,MR和发布前触发。灰度过程中手动执行E2E用例,以自定义环境变量的形式指定页面路径、元素选择器、相似度阈值等。测试通过后修改页面配置,引流至新路由。通过用户故障群和页面反馈入口响应用户反馈,结合前端埋点报表观察线上使用情况和确定灰度策略。通过完善单元测试、E2E测试和制定合理的灰度策略,针对特定模板的迁移已顺利完成,期间未收到线上故障反馈。

后续迁移策略将以老仓库中改动较频繁文件优先入手,测试用例先行,借助自动代码转换工具快速平稳迁移,线上埋点数据做辅助。

收益

活跃代码陆续迁移,结束多仓库并行,减少维护心智。之前我们的常态是新老仓库并行,开发一个完整的业务功能时要在ts和js,选项式API和setup语法之间频繁切换,心智负担较重。活跃代码陆续迁移至vue3新仓库,结合新的框架特性和实用工具,能够更专注于业务逻辑本身。

图片

核心模块重构,“巨石store”轻量化,提高可维护性和可演进性,分层结构保障核心逻辑稳定性。原本配置能力扩充时需要在复杂的数据流中“走迷宫”,耦合严重,通用代码影响范围大,常常出现A业务需求上线导致B业务不可用的情况。利用整洁架构进行分层设计后,新增一种审核模式仅需在适配器层新增对应物料和action,用例层新增用例。无需修改实体层和其他业务相关的用例、通用页面、物料等。

图片

图片

业务逻辑的重新梳理,弥补测试用例空缺。领域提取是对业务逻辑的重新梳理,前端能加深业务理解。稳定性至上的模块很长时间缺少测试用例,造成对开发人员的经验、能力依赖极大。完善核心链路的测试用例能有效降低回归成本,保障系统稳定性。

工具沉淀,组内复用。自动代码转换工具和基于AI能力的前端E2E测试方案为后续组内其他项目的框架升级和迁移提供了便利。

总结与展望

在2023年开始渐进式升级Vue3后,我们经历了很长一段时间的多仓库并行。在新业务不断接入、开发新成员加入的背景下,这样的模式无疑提升了开发门槛和维护心智。本次活跃代码的陆续迁移结束了多仓库并行的现状,同时在整个实践过程中我们为审核平台这个大型复杂项目前端引入了整洁架构的思想,为后续的开发维护提供了一种新的思路。沉淀了一套自动化vue2代码转vue3 setup的工具,可为后台项目的框架升级提供便利。同时借助AI能力提升前端E2E测试的效率,利用计算机视觉辅助前端UI自动化测试。有几点心得:

完美重构、敏捷重构、系统稳定性难以平衡。

图片

既然下定决心对年久失修的代码进行重构,我们一定是追求极致优化和整洁的。但是需求现状是不断有新特性进来,战线拉的太长必将导致抹平差异的成本增加,因此敏捷性也很重要。同时底线是关注系统的可靠性和稳定性。这三者一定程度上存在矛盾,需平衡:

  • 任务拆分,基于埋点数据选择最重要或最紧急的链路进行重构,而非追求大而全,先落地架构,后扩充功能

  • 测试用例先行,重构必将引入风险,用例先行能最大程度保障核心功能的稳定迁移

  • 制定合理的灰度策略,新增路由,以页面配置形式进行灰度,优先uv较低的页面验证,并保证及时的反馈渠道

  • 持续优化,重构应成为一种思想,在迭代中持续优化

整洁架构非银弹,容易过度设计,学习门槛较高。

  • 按模块重构,而非项目

  • 针对新架构,制定长期维护计划,组织团队培训,降低学习成本

多仓库迁移路线:数据为支撑,测试用例先行,借助自动代码转换工具和视觉辅助UI自动化测试,制定合理的灰度策略并建立及时的故障反馈和响应渠道。对于大型复杂项目或模块,先进行面向迁移的重构,也能起到事半功倍的作用。

对我们而言,迁移的结束只是起点,基于更整洁的架构和更先进的前端框架,未来仍有很多发力点:

  • 对于迁移过程中沉淀下来的新架构,需不断优化和改进,以适应复杂的业务场景。

  • 在更多的业务场景下评估迁移后的性能改进,确保用户体验得到提升。

  • 持续审查并解决在迁移过程中发现的技术债务。

  • 持续建设自动代码转换工具,赋能团队内其他项目。

  • 视觉辅助UI自动化测试的方案,进一步抽象,给到不熟悉自动化测试的团队成员“开箱即用”。

  • ...

-End-

作者丨伍月、莫小谦

  • 20
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值