今年的情人节,给心爱的她一个不一样的礼物吧

2022-02-12 12:21:29 +08:00
 willin

给大家隆重介绍一个网站,憨憨.我爱你

参考示例网站:


申请流程

打开官网首页: 憨憨.我爱你

(由于该网站数据库使用的是 PlanetScale 免费服务,位于美东,所以访问可能会稍微有点慢。请耐心等待。)

使用 Authing 账号登录(可以手机、邮箱注册,Github 账号登录,或者微信小程序扫码,后续还会添加更多登录方式)。

可以分别点击进入域名申请和邮箱申请。

域名申请

域名申请界面如下:

支持的绑定方式有三种:

其中还有一项 Proxied ( CDN ),如果不知道作用,可以尝试开启或关闭来测试。

邮箱申请

域名申请界面如下:

目前使用了 Cloudflare 的邮件转发服务,但由于暂不支持 IDN 域名,所以可以提前抢注,第一时间拥有。

其他说明

如需帮助

欢迎在 Github 上关注我: willin ,如果在为心爱的她准备礼物时遇到问题,可以为你免费提供技术咨询。

还想要其他的域名

感觉慷慨

跃跃欲试

或许你也有很多想法,想要实现。您可以:


开源

接下来,开始一个重要的环节。俗话说,授人以鱼不如授人以渔。我将 憨憨.我爱你 的源码 进行开源,并详细讲解一下设计与实现的全部过程。

设计

这个项目大概花了我 3 个小时左右完成。为了扬长避短,我使用了 UI 框架,所以就没有额外的 UI 设计了,直接用几个基础组件快速上手该项目。

技术选型

首先,第一步是技术选型。因为我要提供的是一项免费的服务,所以尽量也选择一些免费的服务商,及一些相关的技术栈。

服务商的选择:

其实,本来是想使用 Cloudflare 全家桶的,就是用 Cloudflare Pages (静态网站) + Cloudflare Workers ( Serverless 方法执行)及 KV (键值对存储),但是由于时间和精力的限制,所以就采用了更简单快捷的实现方式。

技术栈:

数据库设计

由于我用的是 Authing 用户集成,所以省去了用户表的设计和用户相关接口的设计。

// 域名类型
enum DomainType {
  A
  AAAA
  CNAME
}

// 审核状态
enum Status {
  // 待审核
  PENDING
  // 激活
  ACTIVE
  // 已删除
  DELETED
  // 被管理员禁用
  BANNED
}

// 域名记录表
model Domains {
  // Cloudflare 域名记录的 ID ,同时作为表主键 id
  id        String      @id @default(cuid()) @db.VarChar(32)
  // 自增 id ,没有什么实际意义,只是为了减少查询(毕竟有调用配额限制),实际项目中不推荐自增主键及自增 id 使用
  no        Int         @default(autoincrement()) @db.UnsignedInt
  name      String      @db.VarChar(255)
  punycode  String      @db.VarChar(255)
  type      DomainType  @default(CNAME)
  content   String      @default("") @db.VarChar(255)
  proxied   Boolean     @default(true)
  // Authing 的用户 id
  user      String      @default("") @db.VarChar(32)
  status    Status      @default(ACTIVE)
  createdAt DateTime    @default(now())
  updatedAt DateTime    @updatedAt

  @@index([no])
  @@index([name, punycode])
  @@index([user, status, createdAt])
}

// 邮箱表
model Emails {
  // 由于 Cloudflare 邮箱还没有提供开放接口,所以需要人工审核和操作,这里会填入默认的 cuid 作为主键 id
  id        String      @id @default(cuid()) @db.VarChar(32)
  // 自增 id ,没有什么实际意义,只是为了减少查询(毕竟有调用配额限制),实际项目中不推荐自增主键及自增 id 使用
  no        Int         @default(autoincrement()) @db.UnsignedInt
  name      String      @db.VarChar(255)
  punycode  String      @db.VarChar(255)
  content   String      @default("") @db.VarChar(255)
  user      String      @default("") @db.VarChar(32)
  status    Status      @default(PENDING)
  createdAt DateTime    @default(now())
  updatedAt DateTime    @updatedAt

  @@index([no])
  @@index([name, punycode])
  @@index([user, status, createdAt])
}

非常简单,参考注释说明。另外,我本来是打算只存一个名称的,但由于会重复注册,比如说我注册了一个中文名老王,你又注册了一个对应的 punycode 代码名 xn--qbyt9x,就会冲突,所以索性(偷懒)都存下吧。

技术准备

先把 Next.js 网站框架搭建起来,部署到 Vercel 上进行测试。可以再加上 Tailwind CSS 和 Authing SSO 集成。第一步准备工作就算完成了。

接口设计

为了快速(偷懒)实现,我分别创建了增删改查四个接口。

查询接口:

graph TD
    Start1(Start)
    --> |检查域名是否存在| check1{是否登录}
    --> |F| fail1[失败]
    --> End1(End)
    check1 --> |T| check12{检查是否为保留域名}
    --> |T| fail1
    check12 --> |F| check13{检查数据库重复}
    --> |T| fail1
    check13 --> |F| success1[允许注册]
    --> End1

创建接口:

graph TD
    Start2(Start)
    --> |创建域名| check2{是否登录}
    --> |F| fail2[失败]
    --> End2(End)
    check2 --> |T| check22{检查是否为保留域名}
    --> |T| fail2
    check22 --> |F| check23{用户是否已经注册域名}
    --> |T| fail2
    check23 --> |F| check24{检查数据库重复}
    --> |T| fail2
    check24 --> |F| success2[注册]
    --> End2

数据库查询用户是否已经注册域名和是否存在同名可以用一次查询完成,这里为了提高查询性能进行了拆分。

修改接口:

graph TD
    Start3(Start)
    --> |检查域名是否存在| check3{是否登录}
    --> |F| fail3[失败]
    --> End3(End)
    check3 --> |T| check32{修改 id 和 用户匹配的记录}
    --> |F 修改记录数 0| fail3
    check32 --> |T| success3[修改成功]
    --> End3

删除接口与修改接口同。邮箱接口与域名类似,不再赘述。

代码实现

封装 Cloudflare SDK

当然也有现成的库可以直接用,但是因为没几行代码,我就自己手撸了。

import { Domains } from '@prisma/client';
import { CfAPIToken, CfZoneId } from '../config';

const BASE_URL = 'https://api.cloudflare.com/client/v4';

export type CFResult = {
  success: boolean;
  result: {
    id: string;
  };
};

const headers = {
  Authorization: `Bearer ${CfAPIToken}`,
  'Content-Type': 'application/json'
};

export const createDomain = async (
  form: Pick<Domains, 'name' | 'content' | 'type' | 'proxied'>
): Promise<string> => {
  const res = await fetch(`${BASE_URL}/zones/${CfZoneId}/dns_records`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ ...form, ttl: 1 })
  });
  const data = (await res.json()) as CFResult;
  if (data.success) {
    return data.result.id;
  }
  return '';
};

export const updateDomain = async (
  id: string,
  form: Pick<Domains, 'name' | 'content' | 'type' | 'proxied'>
): Promise<boolean> => {
  const res = await fetch(`${BASE_URL}/zones/${CfZoneId}/dns_records/${id}`, {
    method: 'PATCH',
    headers,
    body: JSON.stringify({ ...form, ttl: 1 })
  });
  const data = (await res.json()) as CFResult;
  console.error(data);
  return data.success;
};

export const deleteDomain = async (id: string): Promise<boolean> => {
  const res = await fetch(`${BASE_URL}/zones/${CfZoneId}/dns_records/${id}`, {
    method: 'DELETE',
    headers
  });
  const data = (await res.json()) as CFResult;
  return !!data.result.id;
};

封装校验工具类

需要有一定的正则基础,如果你需要在线调试工具,可以访问: regexper.js.cool

域名( CNAME )校验正则:

/^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$/;

邮箱校验正则:

/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;

IPv4 校验正则:

/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;

IPv6 校验正则:

/^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:)))(%.+)?$/;

页面请求封装

以域名注册提交为例:

async function submit(e: SyntheticEvent) {
  e.preventDefault();
  // 因为我 Vue 、React 都会用,且用的都比较少
  // 所以获取表单数据,我用的是 Vanilla JS 方式,通用性更高
  // 如果你不熟悉,可以用 React 的方式
  const target = e.currentTarget as typeof e.currentTarget & {
    type: { value: DomainType };
    content: { value: string };
    proxied: { checked: boolean };
  };
  const type = target.type.value;
  const content = target.content.value;
  if (!validateContent(type, content)) {
    return;
  }
  const form = {
    type,
    content,
    proxied: target.proxied.checked,
    name,
    punycode: toASCII(name)
  };
  // 我建议对 Fetch 进行封装,为了追求效率(偷懒),我就没有做
  const res = await fetch(`/api/domain/create`, {
    method: 'POST',
    body: JSON.stringify(form),
    headers: {
      'content-type': 'application/json'
    }
  });
  // 所以像这样的处理,就非常不优雅,而且还可以统一封装,将错误提示使用通知条组件之类的
  const result = (await res.json()) as { success: boolean; id: string };
  if (result.success) {
    router.reload();
  } else {
    alert('出错啦!请稍后重试');
  }
}

可复用的代码可以进行封装。参考软件工程的思想:高内聚、低耦合。我这里举的是一个较为反面的教材,代码臃肿、可读性低。

注意点

剩下的代码部分就枯燥且简单了。


差不多就讲这么多吧。记得分享哦!

willin | 憨憨.我爱你 | 项目源码

3561 次点击
所在节点    分享创造
24 条回复
BlackCat02
2022-02-14 11:18:20 +08:00
有女朋友了么弟弟~
sadfQED2
2022-02-14 15:08:51 +08:00
写的不错,下次别写了
goodryb
2022-02-14 15:44:17 +08:00
什么年代了,还搞这种,学两句土味情话,送一束花不比这强 😳
neilyoone
2022-02-14 17:28:58 +08:00
恕我直言,典型的 IT 直男想法。。。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/833368

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX