给大家隆重介绍一个网站,憨憨.我爱你
参考示例网站:
打开官网首页: 憨憨.我爱你
(由于该网站数据库使用的是 PlanetScale 免费服务,位于美东,所以访问可能会稍微有点慢。请耐心等待。)
使用 Authing 账号登录(可以手机、邮箱注册,Github 账号登录,或者微信小程序扫码,后续还会添加更多登录方式)。
可以分别点击进入域名申请和邮箱申请。
域名申请界面如下:
支持的绑定方式有三种:
willin.github.io
1.2.3.4
(你服务器的 ip 地址)其中还有一项 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
删除接口与修改接口同。邮箱接口与域名类似,不再赘述。
当然也有现成的库可以直接用,但是因为没几行代码,我就自己手撸了。
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('出错啦!请稍后重试');
}
}
可复用的代码可以进行封装。参考软件工程的思想:高内聚、低耦合。我这里举的是一个较为反面的教材,代码臃肿、可读性低。
useState
之类的 Hooks ,尽量放在页面级别,不要放在组件级别(尤其是会循环生成的组件)useMemo
、 debounce
之类的方式进行缓存、防抖、限流,以提升应用性能swr
及其内部的一些核心思想会很有裨益剩下的代码部分就枯燥且简单了。
差不多就讲这么多吧。记得分享哦!
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.